From d392cd9768a90ffe400cb0c5f6af6413f6fe8c86 Mon Sep 17 00:00:00 2001 From: wolo Date: Thu, 9 Jul 2026 09:12:12 +0000 Subject: [PATCH 1/2] feat(auth): add CredentialStore and cache GCP credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add auth.CredentialStore (keyed by app+user+slot) with an in-memory implementation, and use it in the GCP provider so a resolved credential is reused across requests instead of hitting the credential service on every tool call (each miss is a round-trip plus up to a ~10s pending poll). - auth.CredentialStore / InMemoryCredentialStore: concurrency-safe, agent-free (keyed by explicit strings), with per-entry expiry and a small clock-skew margin. TokenSource-backed providers are self-caching and do not need it. - gcp.Provider caches via the store (default in-memory, override with WithStore), keyed by (appName, userID, resource); the GCP client now surfaces the service's expireTime so entries expire correctly. Deferred (documented): SessionStateCredentialStore lands with the interactive consent flow (A3) — it persists across the consent round-trip and needs write access to session state, which the read-only RoundTripper/provider path does not have. Downstream-401 invalidation (force_refresh) is a follow-up. --- auth/gcp/agentidentity.go | 2 +- auth/gcp/client.go | 55 +++++++++++++++++------ auth/gcp/connector.go | 2 +- auth/gcp/provider.go | 31 ++++++++++++- auth/gcp/provider_test.go | 63 +++++++++++++++++++++++++++ auth/store.go | 91 ++++++++++++++++++++++++++++++++++++++ auth/store_test.go | 92 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 auth/store.go create mode 100644 auth/store_test.go diff --git a/auth/gcp/agentidentity.go b/auth/gcp/agentidentity.go index 5ec60907e..086941bf0 100644 --- a/auth/gcp/agentidentity.go +++ b/auth/gcp/agentidentity.go @@ -46,7 +46,7 @@ type consentDetail struct { func (r agentIdentityResponse) result(resource string) (outcome, error) { switch { case r.Success != nil: - return credOutcome{header: r.Success.Header, token: r.Success.Token}, nil + return credOutcome{header: r.Success.Header, token: r.Success.Token, expiresAt: parseExpireTime(r.Success.ExpireTime)}, nil case r.URIConsentRequired != nil: return consentOutcome{authURI: r.URIConsentRequired.AuthorizationURI, nonce: r.URIConsentRequired.ConsentNonce}, nil case r.ConsentRejected != nil: diff --git a/auth/gcp/client.go b/auth/gcp/client.go index b2db34f3b..29d0eef5a 100644 --- a/auth/gcp/client.go +++ b/auth/gcp/client.go @@ -131,11 +131,18 @@ type Request struct { // reports a non-interactive pending state (up to the configured poll timeout). // If interactive consent is required it returns an [auth.ConsentRequiredError]. func (c *Client) RetrieveCredential(ctx context.Context, req Request) (auth.Credential, error) { + cred, _, err := c.retrieve(ctx, req) + return cred, err +} + +// retrieve is RetrieveCredential plus the credential's expiry (zero when the +// service does not report one), which callers use to cache the result. +func (c *Client) retrieve(ctx context.Context, req Request) (auth.Credential, time.Time, error) { if req.Resource == "" { - return nil, fmt.Errorf("gcp: RetrieveCredential requires a Resource") + return nil, time.Time{}, fmt.Errorf("gcp: RetrieveCredential requires a Resource") } if req.UserID == "" { - return nil, fmt.Errorf("gcp: RetrieveCredential requires a UserID") + return nil, time.Time{}, fmt.Errorf("gcp: RetrieveCredential requires a UserID") } retrieve := c.retrieveAgentIdentity @@ -148,29 +155,33 @@ func (c *Client) RetrieveCredential(ctx context.Context, req Request) (auth.Cred for { res, err := retrieve(ctx, req) if err != nil { - return nil, err + return nil, time.Time{}, err } switch o := res.(type) { case credOutcome: - return mapCredential(o.header, o.token) + cred, err := mapCredential(o.header, o.token) + if err != nil { + return nil, time.Time{}, err + } + return cred, o.expiresAt, nil case consentOutcome: - return nil, &auth.ConsentRequiredError{AuthURI: o.authURI, Nonce: o.nonce} + return nil, time.Time{}, &auth.ConsentRequiredError{AuthURI: o.authURI, Nonce: o.nonce} case rejectedOutcome: - return nil, fmt.Errorf("%w for %q", ErrConsentRejected, req.Resource) + return nil, time.Time{}, fmt.Errorf("%w for %q", ErrConsentRejected, req.Resource) case pendingOutcome: remaining := time.Until(deadline) if remaining <= 0 { - return nil, fmt.Errorf("%w for %q", ErrPollTimeout, req.Resource) + return nil, time.Time{}, fmt.Errorf("%w for %q", ErrPollTimeout, req.Resource) } wait := min(backoff, remaining) select { case <-ctx.Done(): - return nil, ctx.Err() + return nil, time.Time{}, ctx.Err() case <-time.After(wait): } backoff = min(backoff*2, maxBackoff) default: - return nil, fmt.Errorf("gcp: unexpected retrieval outcome %T", res) + return nil, time.Time{}, fmt.Errorf("gcp: unexpected retrieval outcome %T", res) } } } @@ -180,8 +191,12 @@ func (c *Client) RetrieveCredential(ctx context.Context, req Request) (auth.Cred type outcome interface{ isOutcome() } type ( - // credOutcome carries a successfully retrieved {header, token} credential. - credOutcome struct{ header, token string } + // credOutcome carries a successfully retrieved {header, token} credential and + // its expiry (zero when the service does not report one). + credOutcome struct { + header, token string + expiresAt time.Time + } // pendingOutcome means retrieval is still pending; poll again. pendingOutcome struct{} // consentOutcome means interactive consent is required at authURI. @@ -201,8 +216,22 @@ func (rejectedOutcome) isOutcome() {} // credentialPayload is the {header, token} success shape shared by both services // (under "success" for Agent Identity, "response" for the IAM Connector operation). type credentialPayload struct { - Token string `json:"token"` - Header string `json:"header"` + Token string `json:"token"` + Header string `json:"header"` + ExpireTime string `json:"expireTime"` +} + +// parseExpireTime parses a proto Timestamp (RFC 3339) into a time.Time, or +// returns the zero time when empty or malformed. +func parseExpireTime(s string) time.Time { + if s == "" { + return time.Time{} + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{} + } + return t } // mapCredential maps the service's {header, token} tuple to an [auth.Credential]: diff --git a/auth/gcp/connector.go b/auth/gcp/connector.go index 1d1e8e17f..2acdde4ca 100644 --- a/auth/gcp/connector.go +++ b/auth/gcp/connector.go @@ -56,7 +56,7 @@ func (o connectorOperation) result(resource string) (outcome, error) { if o.Response == nil { return nil, fmt.Errorf("gcp: connector operation done but returned no credential for %q", resource) } - return credOutcome{header: o.Response.Header, token: o.Response.Token}, nil + return credOutcome{header: o.Response.Header, token: o.Response.Token, expiresAt: parseExpireTime(o.Response.ExpireTime)}, nil } if md := o.Metadata; md != nil { switch { diff --git a/auth/gcp/provider.go b/auth/gcp/provider.go index 009119072..5d7d4f9ae 100644 --- a/auth/gcp/provider.go +++ b/auth/gcp/provider.go @@ -45,6 +45,11 @@ type ProviderConfig struct { // Client reaches the credential services. When nil, a default client (backed // by Application Default Credentials) is created lazily on first use. Client *Client + // Store caches resolved credentials across requests (keyed by app, user, and + // 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 } // NewProvider returns an [auth.CredentialProvider] that resolves credentials for @@ -63,11 +68,16 @@ func NewProvider(scheme Scheme, cfg *ProviderConfig) (auth.CredentialProvider, e // Defensive copy: the provider outlives this call and reads Scopes on every // request, so it must not alias a slice the caller can mutate later. scheme.Scopes = slices.Clone(scheme.Scopes) - return &provider{scheme: scheme, client: cfg.Client}, nil + store := cfg.Store + if store == nil { + store = auth.NewInMemoryCredentialStore() + } + return &provider{scheme: scheme, store: store, client: cfg.Client}, nil } type provider struct { scheme Scheme + store auth.CredentialStore mu sync.Mutex client *Client @@ -86,16 +96,33 @@ func (p *provider) Credential(ctx context.Context) (auth.Credential, error) { return nil, errors.New("gcp: ADK context has no user id") } + key := auth.CredentialKey{AppName: rc.AppName(), UserID: 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 { + return cred, nil + } + client, err := p.resolveClient(ctx) if err != nil { return nil, err } - return client.RetrieveCredential(ctx, Request{ + cred, expiresAt, err := client.retrieve(ctx, Request{ Resource: p.scheme.Name, UserID: userID, Scopes: p.scheme.Scopes, ContinueURI: p.scheme.ContinueURI, }) + if err != nil { + return nil, err + } + // Cache only when the service reported an expiry: a zero time means "never + // expires" to the store, and the GCP services omit it only when the lifetime + // is unknown — caching that would risk serving a stale credential forever. + // Best-effort: a store write failure must not fail auth. + if !expiresAt.IsZero() { + _ = p.store.Set(ctx, key, cred, expiresAt) + } + return cred, nil } // resolveClient returns the configured client, creating a default one (backed by diff --git a/auth/gcp/provider_test.go b/auth/gcp/provider_test.go index 7b7d6f5fe..e0f3259cf 100644 --- a/auth/gcp/provider_test.go +++ b/auth/gcp/provider_test.go @@ -20,6 +20,7 @@ import ( "io" "net/http" "net/http/httptest" + "sync/atomic" "testing" "google.golang.org/adk/v2/auth" @@ -87,6 +88,68 @@ func TestNewProviderValidatesScheme(t *testing.T) { } } +func TestProviderCachesCredential(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + _, _ = io.WriteString(w, `{"success":{"token":"tok","header":"Authorization: Bearer","expireTime":"2999-01-01T00:00:00Z"}}`) + })) + defer srv.Close() + + client, err := gcp.NewClient(t.Context(), &gcp.Config{ + HTTPClient: srv.Client(), + AgentIdentityEndpoint: srv.URL, + }) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + // Default (in-memory) store; two resolves for the same app+user+resource. + p, err := gcp.NewProvider(gcp.Scheme{Name: "projects/p/locations/l/authProviders/ap"}, &gcp.ProviderConfig{Client: client}) + if err != nil { + t.Fatalf("NewProvider() error = %v", err) + } + + for i := range 2 { + if _, err := p.Credential(adkContext(t, "user-1")); err != nil { + t.Fatalf("call %d: Credential() error = %v", i, err) + } + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Errorf("service calls = %d, want 1 (second resolve should hit the cache)", got) + } +} + +func TestProviderSkipsCacheWithoutExpiry(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + // No expireTime: lifetime unknown, so the provider must not cache. + _, _ = io.WriteString(w, `{"success":{"token":"tok","header":"Authorization: Bearer"}}`) + })) + defer srv.Close() + + client, err := gcp.NewClient(t.Context(), &gcp.Config{ + HTTPClient: srv.Client(), + AgentIdentityEndpoint: srv.URL, + }) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + p, err := gcp.NewProvider(gcp.Scheme{Name: "projects/p/locations/l/authProviders/ap"}, &gcp.ProviderConfig{Client: client}) + if err != nil { + t.Fatalf("NewProvider() error = %v", err) + } + + for i := range 2 { + if _, err := p.Credential(adkContext(t, "user-1")); err != nil { + t.Fatalf("call %d: Credential() error = %v", i, err) + } + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Errorf("service calls = %d, want 2 (unknown expiry must not be cached)", got) + } +} + // adkContext returns an ADK invocation context (recoverable via agent.FromContext) // for the given user. func adkContext(t *testing.T, userID string) context.Context { diff --git a/auth/store.go b/auth/store.go new file mode 100644 index 000000000..2990a1080 --- /dev/null +++ b/auth/store.go @@ -0,0 +1,91 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "sync" + "time" +) + +// expirySkew is how long before its stated expiry a cached credential is treated +// as expired, to absorb clock skew and in-flight latency. +const expirySkew = 10 * time.Second + +// CredentialKey identifies a cached credential: the app, the acting user, and a +// caller-chosen slot (typically the target resource or scheme name). +type CredentialKey struct { + // AppName is the ADK application name. + AppName string + // UserID is the acting end user's identity. + UserID string + // Key is a caller-chosen slot, typically the target resource or scheme name. + Key string +} + +// CredentialStore caches resolved credentials across calls, keyed by +// [CredentialKey]. It exists so network-backed providers (e.g. auth/gcp) avoid a +// credential-service round-trip on every request; self-caching providers +// (oauth2.TokenSource) do not need it. Implementations must be safe for +// concurrent use. +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. + Set(ctx context.Context, key CredentialKey, cred Credential, expiresAt time.Time) error +} + +// InMemoryCredentialStore is a concurrency-safe, process-local [CredentialStore] +// (per app+user+key, across sessions). It mirrors adk-python's +// InMemoryCredentialService. +type InMemoryCredentialStore struct { + mu sync.Mutex + entries map[CredentialKey]cacheEntry +} + +type cacheEntry struct { + cred Credential + expiresAt time.Time +} + +// NewInMemoryCredentialStore returns an empty [InMemoryCredentialStore]. +func NewInMemoryCredentialStore() *InMemoryCredentialStore { + return &InMemoryCredentialStore{entries: make(map[CredentialKey]cacheEntry)} +} + +// Get implements [CredentialStore]. +func (s *InMemoryCredentialStore) Get(_ context.Context, key CredentialKey) (Credential, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.entries[key] + if !ok { + return nil, false, nil + } + if !e.expiresAt.IsZero() && time.Now().Add(expirySkew).After(e.expiresAt) { + delete(s.entries, key) + return nil, false, nil + } + return e.cred, true, nil +} + +// Set implements [CredentialStore]. +func (s *InMemoryCredentialStore) Set(_ context.Context, key CredentialKey, cred Credential, expiresAt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + s.entries[key] = cacheEntry{cred: cred, expiresAt: expiresAt} + return nil +} + +var _ CredentialStore = (*InMemoryCredentialStore)(nil) diff --git a/auth/store_test.go b/auth/store_test.go new file mode 100644 index 000000000..265b518db --- /dev/null +++ b/auth/store_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth_test + +import ( + "testing" + "time" + + "google.golang.org/adk/v2/auth" +) + +func TestInMemoryCredentialStoreGetSet(t *testing.T) { + ctx := t.Context() + s := auth.NewInMemoryCredentialStore() + key := auth.CredentialKey{AppName: "app", UserID: "user", Key: "res"} + cred := auth.BearerCredential{Token: "tok"} + + if _, ok, err := s.Get(ctx, key); err != nil || ok { + t.Fatalf("Get() on empty store = (_, %v, %v), want (_, false, nil)", ok, err) + } + if err := s.Set(ctx, key, cred, time.Time{}); err != nil { + t.Fatalf("Set() error = %v", err) + } + got, ok, err := s.Get(ctx, key) + if err != nil || !ok || got != cred { + t.Fatalf("Get() after Set = (%v, %v, %v), want the stored credential", got, ok, err) + } + + // A different key is a miss. + if _, ok, _ := s.Get(ctx, auth.CredentialKey{AppName: "app", UserID: "other", Key: "res"}); ok { + t.Error("Get() for a different user = hit, want miss") + } +} + +func TestInMemoryCredentialStoreExpiry(t *testing.T) { + ctx := t.Context() + s := auth.NewInMemoryCredentialStore() + key := auth.CredentialKey{Key: "res"} + cred := auth.APIKeyCredential{Name: "X", Value: "v"} + + if err := s.Set(ctx, key, cred, time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + if _, ok, _ := s.Get(ctx, key); !ok { + t.Error("Get() with far-future expiry = miss, want hit") + } + + if err := s.Set(ctx, key, cred, time.Now().Add(-time.Second)); err != nil { + t.Fatal(err) + } + if _, ok, _ := s.Get(ctx, key); ok { + t.Error("Get() with past expiry = hit, want miss") + } + + // Within the clock-skew window the entry is treated as expired. + if err := s.Set(ctx, key, cred, time.Now().Add(2*time.Second)); err != nil { + t.Fatal(err) + } + if _, ok, _ := s.Get(ctx, key); ok { + t.Error("Get() within skew window = hit, want miss") + } +} + +func TestInMemoryCredentialStoreOverwrite(t *testing.T) { + ctx := t.Context() + s := auth.NewInMemoryCredentialStore() + key := auth.CredentialKey{Key: "res"} + first := auth.BearerCredential{Token: "a"} + second := auth.BearerCredential{Token: "b"} + + if err := s.Set(ctx, key, first, time.Time{}); err != nil { + t.Fatal(err) + } + if err := s.Set(ctx, key, second, time.Time{}); err != nil { + t.Fatal(err) + } + if got, _, _ := s.Get(ctx, key); got != second { + t.Fatalf("Get() after overwrite = %v, want the second credential", got) + } +} From b8ec0e5ca81f9df18358f56313237fea329594d8 Mon Sep 17 00:00:00 2001 From: wolo Date: Sun, 19 Jul 2026 14:38:47 +0000 Subject: [PATCH 2/2] refactor(auth/gcp): return a Retrieval result instead of a wrapper method RetrieveCredential was a thin public wrapper that dropped the expiry from an unexported retrieve() the provider called directly, so the real prod path used the unexported variant while the public one was test-only. Collapse both into a single RetrieveCredential returning *Retrieval{Credential, ExpiresAt}, making the expiry a documented public field (zero => "do not cache"). --- auth/gcp/client.go | 37 +++++++++++++++++++------------------ auth/gcp/client_test.go | 22 +++++++++++++++++++--- auth/gcp/provider.go | 8 ++++---- 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/auth/gcp/client.go b/auth/gcp/client.go index 29d0eef5a..7bd637888 100644 --- a/auth/gcp/client.go +++ b/auth/gcp/client.go @@ -127,22 +127,23 @@ type Request struct { ContinueURI string } +// Retrieval is the result of [Client.RetrieveCredential]. +type Retrieval struct { + Credential auth.Credential + // ExpiresAt is the credential's expiry, or the zero time when the service + // reports no lifetime. + ExpiresAt time.Time +} + // RetrieveCredential retrieves a credential for req, polling while the service // reports a non-interactive pending state (up to the configured poll timeout). // If interactive consent is required it returns an [auth.ConsentRequiredError]. -func (c *Client) RetrieveCredential(ctx context.Context, req Request) (auth.Credential, error) { - cred, _, err := c.retrieve(ctx, req) - return cred, err -} - -// retrieve is RetrieveCredential plus the credential's expiry (zero when the -// service does not report one), which callers use to cache the result. -func (c *Client) retrieve(ctx context.Context, req Request) (auth.Credential, time.Time, error) { +func (c *Client) RetrieveCredential(ctx context.Context, req Request) (*Retrieval, error) { if req.Resource == "" { - return nil, time.Time{}, fmt.Errorf("gcp: RetrieveCredential requires a Resource") + return nil, fmt.Errorf("gcp: RetrieveCredential requires a Resource") } if req.UserID == "" { - return nil, time.Time{}, fmt.Errorf("gcp: RetrieveCredential requires a UserID") + return nil, fmt.Errorf("gcp: RetrieveCredential requires a UserID") } retrieve := c.retrieveAgentIdentity @@ -155,33 +156,33 @@ func (c *Client) retrieve(ctx context.Context, req Request) (auth.Credential, ti for { res, err := retrieve(ctx, req) if err != nil { - return nil, time.Time{}, err + return nil, err } switch o := res.(type) { case credOutcome: cred, err := mapCredential(o.header, o.token) if err != nil { - return nil, time.Time{}, err + return nil, err } - return cred, o.expiresAt, nil + return &Retrieval{Credential: cred, ExpiresAt: o.expiresAt}, nil case consentOutcome: - return nil, time.Time{}, &auth.ConsentRequiredError{AuthURI: o.authURI, Nonce: o.nonce} + return nil, &auth.ConsentRequiredError{AuthURI: o.authURI, Nonce: o.nonce} case rejectedOutcome: - return nil, time.Time{}, fmt.Errorf("%w for %q", ErrConsentRejected, req.Resource) + return nil, fmt.Errorf("%w for %q", ErrConsentRejected, req.Resource) case pendingOutcome: remaining := time.Until(deadline) if remaining <= 0 { - return nil, time.Time{}, fmt.Errorf("%w for %q", ErrPollTimeout, req.Resource) + return nil, fmt.Errorf("%w for %q", ErrPollTimeout, req.Resource) } wait := min(backoff, remaining) select { case <-ctx.Done(): - return nil, time.Time{}, ctx.Err() + return nil, ctx.Err() case <-time.After(wait): } backoff = min(backoff*2, maxBackoff) default: - return nil, time.Time{}, fmt.Errorf("gcp: unexpected retrieval outcome %T", res) + return nil, fmt.Errorf("gcp: unexpected retrieval outcome %T", res) } } } diff --git a/auth/gcp/client_test.go b/auth/gcp/client_test.go index 0ab850421..a0f878cce 100644 --- a/auth/gcp/client_test.go +++ b/auth/gcp/client_test.go @@ -44,6 +44,7 @@ func TestRetrieveCredential(t *testing.T) { bodies []string wantCalls int // >0 => assert the number of service calls wantBearer string // expect a bearer credential carrying this token + wantExpiry string // non-empty => expect this timestamp as ExpiresAt wantAPIKey [2]string // expect an API-key credential {name, value} wantConsent [2]string // expect *auth.ConsentRequiredError {authURI, nonce} wantErrIs error // expect errors.Is(err, target) @@ -56,6 +57,13 @@ func TestRetrieveCredential(t *testing.T) { bodies: []string{`{"success":{"token":"tok","header":"Authorization: Bearer"}}`}, wantBearer: "tok", }, + { + name: "agent identity bearer with expiry", + resource: authProviderResource, + bodies: []string{`{"success":{"token":"tok","header":"Authorization: Bearer","expireTime":"2999-01-01T00:00:00Z"}}`}, + wantBearer: "tok", + wantExpiry: "2999-01-01T00:00:00Z", + }, { name: "agent identity custom header", resource: authProviderResource, @@ -127,7 +135,7 @@ func TestRetrieveCredential(t *testing.T) { srv, calls := sequenceServer(tc.bodies...) defer srv.Close() - cred, err := newTestClient(t, srv).RetrieveCredential(t.Context(), + got, err := newTestClient(t, srv).RetrieveCredential(t.Context(), Request{Resource: tc.resource, UserID: "u"}) switch { @@ -135,12 +143,20 @@ func TestRetrieveCredential(t *testing.T) { if err != nil { t.Fatalf("RetrieveCredential() error = %v", err) } - wantBearer(t, cred, tc.wantBearer) + wantBearer(t, got.Credential, tc.wantBearer) + if tc.wantExpiry != "" { + want, _ := time.Parse(time.RFC3339, tc.wantExpiry) + if !got.ExpiresAt.Equal(want) { + t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, want) + } + } else if !got.ExpiresAt.IsZero() { + t.Errorf("ExpiresAt = %v, want zero", got.ExpiresAt) + } case tc.wantAPIKey[0] != "": if err != nil { t.Fatalf("RetrieveCredential() error = %v", err) } - wantAPIKey(t, cred, tc.wantAPIKey[0], tc.wantAPIKey[1]) + wantAPIKey(t, got.Credential, tc.wantAPIKey[0], tc.wantAPIKey[1]) case tc.wantConsent[0] != "": var consent *auth.ConsentRequiredError if !errors.As(err, &consent) { diff --git a/auth/gcp/provider.go b/auth/gcp/provider.go index 5d7d4f9ae..6c064cac8 100644 --- a/auth/gcp/provider.go +++ b/auth/gcp/provider.go @@ -106,7 +106,7 @@ func (p *provider) Credential(ctx context.Context) (auth.Credential, error) { if err != nil { return nil, err } - cred, expiresAt, err := client.retrieve(ctx, Request{ + r, err := client.RetrieveCredential(ctx, Request{ Resource: p.scheme.Name, UserID: userID, Scopes: p.scheme.Scopes, @@ -119,10 +119,10 @@ func (p *provider) Credential(ctx context.Context) (auth.Credential, error) { // expires" to the store, and the GCP services omit it only when the lifetime // is unknown — caching that would risk serving a stale credential forever. // Best-effort: a store write failure must not fail auth. - if !expiresAt.IsZero() { - _ = p.store.Set(ctx, key, cred, expiresAt) + if !r.ExpiresAt.IsZero() { + _ = p.store.Set(ctx, key, r.Credential, r.ExpiresAt) } - return cred, nil + return r.Credential, nil } // resolveClient returns the configured client, creating a default one (backed by