Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion auth/gcp/agentidentity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
42 changes: 36 additions & 6 deletions auth/gcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,18 @@ 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) {
func (c *Client) RetrieveCredential(ctx context.Context, req Request) (*Retrieval, error) {
if req.Resource == "" {
return nil, fmt.Errorf("gcp: RetrieveCredential requires a Resource")
}
Expand All @@ -152,7 +160,11 @@ func (c *Client) RetrieveCredential(ctx context.Context, req Request) (auth.Cred
}
switch o := res.(type) {
case credOutcome:
return mapCredential(o.header, o.token)
cred, err := mapCredential(o.header, o.token)
if err != nil {
return nil, err
}
return &Retrieval{Credential: cred, ExpiresAt: o.expiresAt}, nil
case consentOutcome:
return nil, &auth.ConsentRequiredError{AuthURI: o.authURI, Nonce: o.nonce}
case rejectedOutcome:
Expand Down Expand Up @@ -180,8 +192,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.
Expand All @@ -201,8 +217,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]:
Expand Down
22 changes: 19 additions & 3 deletions auth/gcp/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -127,20 +135,28 @@ 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 {
case tc.wantBearer != "":
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) {
Expand Down
2 changes: 1 addition & 1 deletion auth/gcp/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 29 additions & 2 deletions auth/gcp/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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{
r, err := client.RetrieveCredential(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 !r.ExpiresAt.IsZero() {
_ = p.store.Set(ctx, key, r.Credential, r.ExpiresAt)
}
return r.Credential, nil
}

// resolveClient returns the configured client, creating a default one (backed by
Expand Down
63 changes: 63 additions & 0 deletions auth/gcp/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"

"google.golang.org/adk/v2/auth"
Expand Down Expand Up @@ -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 {
Expand Down
91 changes: 91 additions & 0 deletions auth/store.go
Original file line number Diff line number Diff line change
@@ -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)
Loading