Files
upstream-grpc-go/credentials/google/gcp_service_account_identity_credentials.go
upstream-mirror e241b33f26
Some checks failed
codecov / upload (push) Failing after 13m59s
CodeQL / Analyze (push) Failing after 1s
Testing / vet-proto (push) Failing after 1s
Testing / tests (arm, latest) (push) Waiting to run
Testing / extras (latest) (push) Failing after 1s
Testing / static checks (latest-1) (push) Failing after 1s
Testing / tests (-race, latest) (push) Failing after 1s
Testing / tests (i386, latest) (push) Failing after 1s
Testing / tests (latest) (push) Failing after 1s
Testing / tests (latest-1) (push) Failing after 1s
Lock Threads / lock (push) Failing after 1s
govulncheck / govulncheck (.) (push) Failing after 1s
govulncheck / govulncheck (./cmd/protoc-gen-go-grpc) (push) Failing after 1s
govulncheck / govulncheck (./gcp/observability) (push) Failing after 1s
govulncheck / govulncheck (./security/advancedtls) (push) Failing after 1s
govulncheck / govulncheck (./stats/opencensus) (push) Failing after 1s
Stale bot / stale (push) Failing after 1s
snapshot of https://github.com/grpc/grpc-go.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 20:40:15 +05:00

279 lines
9.9 KiB
Go

/*
*
* Copyright 2026 gRPC authors.
*
* 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 google
import (
"context"
"errors"
"fmt"
"sync"
"time"
"cloud.google.com/go/auth"
"cloud.google.com/go/auth/credentials/idtoken"
"cloud.google.com/go/compute/metadata"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/google/internal"
"google.golang.org/grpc/internal/backoff"
"google.golang.org/grpc/internal/transport"
"google.golang.org/grpc/status"
)
const (
// preemptiveRefresh is the window before a token's actual expiration during
// which the token is considered stale. Requests using a stale but still
// valid token will trigger a background asynchronous refresh. This avoids
// blocking the current RPC, preventing periodic latency spikes during token
// refresh.
preemptiveRefresh = 1 * time.Minute
// metadataTimeout is the timeout for the call to the metadata server to
// prevent the background token fetch from hanging indefinitely in case
// of connection issues.
metadataTimeout = 60 * time.Second
)
type gcpServiceAccountIdentityCallCreds struct {
// The following fields are initialized at creation time and are read-only
// after that.
ctx context.Context
audience string
creds *auth.Credentials
backoff backoff.Strategy
// The following fields are protected by mu.
mu sync.Mutex
token *auth.Token
tokenExpiry time.Time // timestamp after which the cached token is considered invalid
preemptiveTokenRefresh time.Time // timestamp after which background preemptive refresh is triggered
fetching chan struct{} // used to ensure a single in-progress background token fetch
nextRetryTime time.Time // timestamp after which we can attempt the next token fetch
retryAttempt int // consecutive fetch failure count used to compute backoff delay
lastErr error // cached error returned from the most recent token fetch attempt
}
func init() {
internal.BackoffStrategy = backoff.DefaultExponential
internal.NewIDTokenCredentials = func(opts *idtoken.Options) (*auth.Credentials, error) {
return idtoken.NewCredentials(opts)
}
}
// NewServiceAccountIdentityCredentials creates a PerRPCCredentials that
// authenticates using a GCP Service Account Identity JWT token for the given
// audience.
//
// This credential fetches the ID token from the GCE metadata server and is
// only valid for use in environments running on GCP. The ctx and audience
// parameters cannot be empty.
//
// The credentials object starts asynchronous background token fetches to
// refresh expired tokens. The provided context propagates cancellation to
// these background tasks. Users should not pass an RPC-scoped context here,
// but rather a context that is valid for the entire lifetime of the
// credentials and should cancel the context when they are done.
//
// # Experimental
//
// Notice: This API is EXPERIMENTAL and may be changed or removed in a
// later release.
func NewServiceAccountIdentityCredentials(ctx context.Context, audience string) (credentials.PerRPCCredentials, error) {
if ctx == nil {
return nil, fmt.Errorf("credentials: ctx cannot be nil")
}
if audience == "" {
return nil, fmt.Errorf("credentials: audience cannot be empty")
}
creds, err := internal.NewIDTokenCredentials(&idtoken.Options{Audience: audience})
if err != nil {
return nil, fmt.Errorf("credentials: failed to create ID token credentials: %v", err)
}
return &gcpServiceAccountIdentityCallCreds{
ctx: ctx,
audience: audience,
creds: creds,
backoff: internal.BackoffStrategy,
}, nil
}
// GetRequestMetadata gets the current request metadata, refreshing tokens if
// required. This implementation follows the PerRPCCredentials interface.
//
// It guarantees that only one underlying token fetch will be executed
// concurrently. If a valid token is cached, it is returned immediately. If
// a fetch recently failed, the cached error is returned until the backoff
// interval expires. Otherwise, it initiates a new token fetch or blocks
// waiting for an already-in-progress fetch to complete.
func (c *gcpServiceAccountIdentityCallCreds) GetRequestMetadata(ctx context.Context, _ ...string) (map[string]string, error) {
ri, _ := credentials.RequestInfoFromContext(ctx)
if err := credentials.CheckSecurityLevel(ri.AuthInfo, credentials.PrivacyAndIntegrity); err != nil {
return nil, fmt.Errorf("credentials: cannot send secure credentials on an insecure connection: %v", err)
}
if md, err := c.cachedRequestMetadata(true); md != nil || err != nil {
return md, err
}
c.mu.Lock()
// Now that we have the lock, did someone else finish the fetch while we
// were waiting for the lock?
md, err := c.cachedRequestMetadataLocked(false)
if md != nil || err != nil {
c.mu.Unlock()
return md, err
}
// If no one is fetching, start it.
if c.fetching == nil {
c.fetching = make(chan struct{})
go c.startFetch()
}
wait := c.fetching
c.mu.Unlock()
select {
case <-wait:
// Return the new cached result from the most recent fetch.
md, err := c.cachedRequestMetadata(false)
if md == nil && err == nil {
return nil, status.Error(codes.Unavailable, "credentials: fetched token is expired")
}
return md, err
case <-ctx.Done():
return nil, ctx.Err()
}
}
// cachedRequestMetadata handles its own locking for the "fast path".
func (c *gcpServiceAccountIdentityCallCreds) cachedRequestMetadata(attemptPreemptiveRefresh bool) (map[string]string, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.cachedRequestMetadataLocked(attemptPreemptiveRefresh)
}
// cachedRequestMetadataLocked returns the cached token if it is valid, or nil
// if it is not. If attemptPreemptiveRefresh is true, it will also trigger a
// background refresh if the token is stale but still valid. It returns the
// cached error if the last fetch failed and the backoff interval has not
// expired.
//
// Returns (nil, nil) if the token is not valid and a new fetch is required. The
// caller is responsible for initiating the fetch or waiting for an in-progress
// fetch to complete.
//
// It must be called with mu locked.
func (c *gcpServiceAccountIdentityCallCreds) cachedRequestMetadataLocked(attemptPreemptiveRefresh bool) (map[string]string, error) {
if c.token != nil && c.isTokenValidLocked() {
if attemptPreemptiveRefresh && c.isTokenStaleLocked() && c.fetching == nil {
c.fetching = make(chan struct{})
go c.startFetch()
}
return map[string]string{"authorization": "Bearer " + c.token.Value}, nil
}
if c.lastErr != nil && time.Now().Before(c.nextRetryTime) {
return nil, c.lastErr
}
return nil, nil
}
// RequireTransportSecurity indicates whether the credentials requires
// transport security.
func (c *gcpServiceAccountIdentityCallCreds) RequireTransportSecurity() bool {
return true
}
// isTokenStaleLocked checks if the token falls within the
// preemptiveRefresh window. It must be called with mu locked.
func (c *gcpServiceAccountIdentityCallCreds) isTokenStaleLocked() bool {
return c.preemptiveTokenRefresh.Before(time.Now())
}
// isTokenValidLocked checks if the token is not expired yet. It must be called
// with mu locked.
func (c *gcpServiceAccountIdentityCallCreds) isTokenValidLocked() bool {
return c.tokenExpiry.After(time.Now())
}
// startFetch initiates a token fetch and updates the credential
// state upon completion.
func (c *gcpServiceAccountIdentityCallCreds) startFetch() {
ctx, cancel := context.WithTimeout(c.ctx, metadataTimeout)
defer cancel()
token, err := c.creds.TokenProvider.Token(ctx)
c.mu.Lock()
defer c.mu.Unlock()
close(c.fetching)
c.fetching = nil
c.updateStateLocked(token, err)
}
// updateStateLocked updates the credentials local token cache and
// backoff state based on the outcome of a background fetch attempt.
//
// If the fetch succeeded, the cached token is updated, and the backoff timers
// and error are reset.
//
// If the fetch failed, backoff attempts are calculated and the error is mapped
// to a gRPC status.
// - If the HTTP request fails with a status that maps to gRPC UNAVAILABLE
// according to HTTP to gRPC status code mappings, it returns UNAVAILABLE.
// - All other HTTP error status codes map to UNAUTHENTICATED.
// - Non-HTTP request failures are mapped to UNAVAILABLE.
//
// It must be called with mu locked.
func (c *gcpServiceAccountIdentityCallCreds) updateStateLocked(token *auth.Token, err error) {
if err != nil {
var mappedErr error
var metadataErr *metadata.Error
if errors.As(err, &metadataErr) {
switch transport.HTTPStatusConvTab[metadataErr.Code] {
case codes.Unavailable:
mappedErr = status.Errorf(codes.Unavailable, "credentials: failed to fetch token from metadata server: %v", err)
default:
mappedErr = status.Errorf(codes.Unauthenticated, "credentials: failed to fetch token from metadata server: %v", err)
}
} else {
mappedErr = status.Errorf(codes.Unavailable, "credentials: failed to fetch ID token: %v", err)
}
c.lastErr = mappedErr
backoffDelay := c.backoff.Backoff(c.retryAttempt)
c.retryAttempt++
c.nextRetryTime = time.Now().Add(backoffDelay)
return
}
c.lastErr = nil
c.retryAttempt = 0
c.nextRetryTime = time.Time{}
c.token = token
// Per gRFC A83, the cached token is considered invalid 30 seconds before its
// actual expiration time to accommodate for clock skew.
c.tokenExpiry = token.Expiry.Add(-30 * time.Second)
c.preemptiveTokenRefresh = c.tokenExpiry.Add(-preemptiveRefresh)
}