snapshot of https://github.com/grpc/grpc-go.git (master, 2026-09-15; blobs > 131072B omitted)
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
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
This commit is contained in:
278
credentials/google/gcp_service_account_identity_credentials.go
Normal file
278
credentials/google/gcp_service_account_identity_credentials.go
Normal file
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
*
|
||||
* 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)
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
/*
|
||||
*
|
||||
* 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_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"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"
|
||||
"google.golang.org/grpc/credentials/google/internal"
|
||||
"google.golang.org/grpc/internal/grpctest"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const defaultTestTimeout = 10 * time.Second
|
||||
|
||||
type s struct {
|
||||
grpctest.Tester
|
||||
}
|
||||
|
||||
func Test(t *testing.T) {
|
||||
grpctest.RunSubTests(t, s{})
|
||||
}
|
||||
|
||||
// gcpTestAuthInfo implements credentials.AuthInfo for testing.
|
||||
type gcpTestAuthInfo struct {
|
||||
credentials.CommonAuthInfo
|
||||
}
|
||||
|
||||
func (t *gcpTestAuthInfo) AuthType() string {
|
||||
return "test"
|
||||
}
|
||||
|
||||
// stubBackoff implements a backoff strategy interface for testing.
|
||||
type stubBackoff struct {
|
||||
backoffDelay time.Duration
|
||||
}
|
||||
|
||||
func (m *stubBackoff) Backoff(int) time.Duration {
|
||||
return m.backoffDelay
|
||||
}
|
||||
|
||||
// stubTokenProvider implements auth.TokenProvider for unit testing.
|
||||
// It supplies mocked token payloads, simulates processing delay, and tracks
|
||||
// invocation frequency to verify caching and backoff behaviors.
|
||||
type stubTokenProvider struct {
|
||||
mu sync.Mutex
|
||||
err error
|
||||
token *auth.Token
|
||||
callCount int // tracks fetch attempts to verify caching and backoff behaviors.
|
||||
}
|
||||
|
||||
func (c *stubTokenProvider) Token(context.Context) (*auth.Token, error) {
|
||||
c.mu.Lock()
|
||||
c.callCount++
|
||||
token := c.token
|
||||
err := c.err
|
||||
c.mu.Unlock()
|
||||
return token, err
|
||||
}
|
||||
|
||||
func (c *stubTokenProvider) setErr(err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.err = err
|
||||
}
|
||||
|
||||
func (c *stubTokenProvider) setToken(token *auth.Token) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.token = token
|
||||
}
|
||||
|
||||
func (c *stubTokenProvider) getCallCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.callCount
|
||||
}
|
||||
|
||||
// setupStubTokenProvider initializes and returns a stubTokenProvider
|
||||
// configured with the specified token value, token expiry, error and delay.
|
||||
func setupStubTokenProvider(token string, err error) *stubTokenProvider {
|
||||
return &stubTokenProvider{
|
||||
err: err,
|
||||
token: &auth.Token{
|
||||
Value: token,
|
||||
Expiry: time.Now().Add(1 * time.Hour), // idtoken package works with hardcoded 1 hour token expiry.
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestGCPServiceAccountIdentityCreds constructs a GCP service account
|
||||
// identity credentials instance with an injected stub token provider.
|
||||
//
|
||||
// It overrides internal.NewIDTokenCredentials to use the stubTokenProvider,
|
||||
// and registers a cleanup function to restore original hook after the test.
|
||||
func setupTestGCPServiceAccountIdentityCreds(ctx context.Context, t *testing.T, stubToken *stubTokenProvider) credentials.PerRPCCredentials {
|
||||
// Override the ID token credentials to use stub token provider.
|
||||
origNewIDTokenCredentials := internal.NewIDTokenCredentials
|
||||
internal.NewIDTokenCredentials = func(*idtoken.Options) (*auth.Credentials, error) {
|
||||
return auth.NewCredentials(&auth.CredentialsOptions{
|
||||
TokenProvider: auth.NewCachedTokenProvider(stubToken, &auth.CachedTokenProviderOptions{})}), nil
|
||||
}
|
||||
t.Cleanup(func() { internal.NewIDTokenCredentials = origNewIDTokenCredentials })
|
||||
|
||||
creds, err := google.NewServiceAccountIdentityCredentials(ctx, "audience")
|
||||
if err != nil {
|
||||
t.Fatalf("NewServiceAccountIdentityCredentials() failed: %v", err)
|
||||
}
|
||||
|
||||
return creds
|
||||
}
|
||||
|
||||
// Test verifies that NewServiceAccountIdentityCredentials returns an error
|
||||
// when called with empty audience.
|
||||
func (s) TestNewServiceAccountIdentityCredentials_EmptyAudience(t *testing.T) {
|
||||
const wantErr = "credentials: audience cannot be empty"
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
if _, err := google.NewServiceAccountIdentityCredentials(ctx, ""); err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||
t.Fatalf("NewServiceAccountIdentityCredentials() returned error = %v, want error containing %q", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Test verifies the successful retrieval of an ID token from the underlying
|
||||
// token provider. It ensures that a valid token is correctly retrieved and
|
||||
// formatted into the required "authorization" metadata header with the
|
||||
// "Bearer " prefix.
|
||||
func (s) TestGCPServiceAccountIdentityCallCreds_GetRequestMetadata(t *testing.T) {
|
||||
const token = "token"
|
||||
stubToken := setupStubTokenProvider(token, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
creds := setupTestGCPServiceAccountIdentityCreds(ctx, t, stubToken)
|
||||
|
||||
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
||||
AuthInfo: &gcpTestAuthInfo{credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}},
|
||||
})
|
||||
|
||||
md, err := creds.GetRequestMetadata(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequestMetadata() failed: %v", err)
|
||||
}
|
||||
|
||||
want := "Bearer " + token
|
||||
if got := md["authorization"]; got != want {
|
||||
t.Errorf("Unexpected token from GetRequestMetadata(), got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Test verifies that when a backoff delay is set, a token fetch failure caches
|
||||
// the failure error, and subsequent calls to GetRequestMetadata before the
|
||||
// backoff window expires return the cached error immediately without
|
||||
// initiating another fetch request to the provider.
|
||||
func (s) TestGCPServiceAccountIdentityCallCreds_Backoff(t *testing.T) {
|
||||
const wantErr = "failed while fetching idToken"
|
||||
|
||||
// Override the backoff strategy with a very large value to guarantee
|
||||
// it doesn't expire during this test.
|
||||
origBackoff := internal.BackoffStrategy
|
||||
internal.BackoffStrategy = &stubBackoff{backoffDelay: 2 * defaultTestTimeout}
|
||||
t.Cleanup(func() { internal.BackoffStrategy = origBackoff })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
stubToken := setupStubTokenProvider("token", errors.New(wantErr))
|
||||
creds := setupTestGCPServiceAccountIdentityCreds(ctx, t, stubToken)
|
||||
|
||||
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
||||
AuthInfo: &gcpTestAuthInfo{credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}},
|
||||
})
|
||||
|
||||
// First call triggers initial fetch, which fails.
|
||||
if _, err := creds.GetRequestMetadata(ctx); err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||
t.Fatalf("GetRequestMetadata() failed with err = %v, want error %q", err, wantErr)
|
||||
}
|
||||
|
||||
// Verify that calls within the backoff window fail immediately with
|
||||
// the cached error without attempting a new token fetch.
|
||||
const numCalls = 3
|
||||
for i := 0; i < numCalls; i++ {
|
||||
if _, err := creds.GetRequestMetadata(ctx); err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||
t.Fatalf("GetRequestMetadata() attempt %d failed with err = %v, want error %q", i, err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that no subsequent fetch attempt made.
|
||||
if got := stubToken.getCallCount(); got != 1 {
|
||||
t.Fatalf("Unexpected call count to token provider: got %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Test verifies that when backoff expired, a subsequent request after an
|
||||
// initial failure triggers a new fetch attempt and returns the new error.
|
||||
func (s) TestGCPServiceAccountIdentityCallCreds_BackoffExpired(t *testing.T) {
|
||||
const (
|
||||
wantErr = "failed while fetching idToken"
|
||||
wantErr2 = "second attempt to fetch token"
|
||||
)
|
||||
|
||||
// Override the backoff strategy with a short delay.
|
||||
const backoffDelay = 10 * time.Millisecond
|
||||
origBackoff := internal.BackoffStrategy
|
||||
internal.BackoffStrategy = &stubBackoff{backoffDelay: backoffDelay}
|
||||
t.Cleanup(func() { internal.BackoffStrategy = origBackoff })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
stubToken := setupStubTokenProvider("token", errors.New(wantErr))
|
||||
creds := setupTestGCPServiceAccountIdentityCreds(ctx, t, stubToken)
|
||||
|
||||
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
||||
AuthInfo: &gcpTestAuthInfo{credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}},
|
||||
})
|
||||
|
||||
// First call triggers token fetch and fails.
|
||||
if _, err := creds.GetRequestMetadata(ctx); err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||
t.Fatalf("GetRequestMetadata() failed with err = %v, want error %q", err, wantErr)
|
||||
}
|
||||
|
||||
if got := stubToken.getCallCount(); got != 1 {
|
||||
t.Fatalf("Unexpected call count to token provider: got %d, want 1", got)
|
||||
}
|
||||
|
||||
// Wait for backoff to expire.
|
||||
select {
|
||||
case <-time.After(2 * backoffDelay):
|
||||
case <-ctx.Done():
|
||||
t.Fatal("Timed out waiting for backoff to expire")
|
||||
}
|
||||
|
||||
// Update token provider with second failure error to distinguish the
|
||||
// new attempt.
|
||||
stubToken.setErr(errors.New(wantErr2))
|
||||
|
||||
// Since backoff is 0s (already expired), the next request
|
||||
// immediately triggers a new fetch attempt.
|
||||
if _, err := creds.GetRequestMetadata(ctx); err == nil || !strings.Contains(err.Error(), wantErr2) {
|
||||
t.Fatalf("GetRequestMetadata() failed with err = %v, want error %q", err, wantErr2)
|
||||
}
|
||||
|
||||
// Verify that a second token fetch actually happened.
|
||||
if got := stubToken.getCallCount(); got != 2 {
|
||||
t.Fatalf("Unexpected call count to token provider: got %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Test verifies that when backoff expires, a subsequent request after an
|
||||
// initial failure triggers a new fetch attempt and returns the new token.
|
||||
func (s) TestGCPServiceAccountIdentityCallCreds_BackoffExpiredRecovery(t *testing.T) {
|
||||
const (
|
||||
wantErr = "failed while fetching idToken"
|
||||
token = "token"
|
||||
)
|
||||
|
||||
// Override the backoff strategy with a short delay.
|
||||
const backoffDelay = 10 * time.Millisecond
|
||||
origBackoff := internal.BackoffStrategy
|
||||
internal.BackoffStrategy = &stubBackoff{backoffDelay: backoffDelay}
|
||||
t.Cleanup(func() { internal.BackoffStrategy = origBackoff })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
stubToken := setupStubTokenProvider(token, errors.New(wantErr))
|
||||
creds := setupTestGCPServiceAccountIdentityCreds(ctx, t, stubToken)
|
||||
|
||||
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
||||
AuthInfo: &gcpTestAuthInfo{credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}},
|
||||
})
|
||||
|
||||
// First call triggers token fetch and fails.
|
||||
if _, err := creds.GetRequestMetadata(ctx); err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||
t.Fatalf("GetRequestMetadata() failed with err = %v, want error %q", err, wantErr)
|
||||
}
|
||||
|
||||
if got := stubToken.getCallCount(); got != 1 {
|
||||
t.Fatalf("Unexpected call count to token provider: got %d, want 1", got)
|
||||
}
|
||||
|
||||
// Wait for backoff to expire.
|
||||
select {
|
||||
case <-time.After(2 * backoffDelay):
|
||||
case <-ctx.Done():
|
||||
t.Fatal("Timed out waiting for backoff to expire")
|
||||
}
|
||||
|
||||
// Update token provider to return a valid token and nil error.
|
||||
stubToken.setErr(nil)
|
||||
|
||||
md, err := creds.GetRequestMetadata(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequestMetadata() failed unexpectedly: %v", err)
|
||||
}
|
||||
|
||||
want := "Bearer " + token
|
||||
if got := md["authorization"]; got != want {
|
||||
t.Errorf("GetRequestMetadata() got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Verify that the second fetch happened successfully.
|
||||
if got := stubToken.getCallCount(); got != 2 {
|
||||
t.Fatalf("Unexpected call count to token provider: got %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Test verifies that when multiple goroutines request a token concurrently,
|
||||
// only a single fetch is executed and all blocked requests successfully
|
||||
// receive the same valid token.
|
||||
func (s) TestGCPServiceAccountIdentityCallCreds_ConcurrentCalls_Success(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
stubToken := setupStubTokenProvider("token", nil)
|
||||
creds := setupTestGCPServiceAccountIdentityCreds(ctx, t, stubToken)
|
||||
|
||||
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
||||
AuthInfo: &gcpTestAuthInfo{credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}},
|
||||
})
|
||||
|
||||
const numCalls = 5
|
||||
errs := make([]error, numCalls)
|
||||
var wg sync.WaitGroup
|
||||
for i := range numCalls {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
_, errs[idx] = creds.GetRequestMetadata(ctx)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("Concurrent call to GetRequestMetadata() failed with err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := stubToken.getCallCount(); got != 1 {
|
||||
t.Fatalf("Unexpected call count to token provider: got %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Test verifies that when multiple goroutines request a token concurrently,
|
||||
// and the first fetch fails, all blocked requests receives the exact same
|
||||
// error after the fetch.
|
||||
func (s) TestGCPServiceAccountIdentityCallCreds_ConcurrentCalls_Failure(t *testing.T) {
|
||||
const wantErr = "failed while fetching idToken"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
stubToken := setupStubTokenProvider("token", errors.New(wantErr))
|
||||
creds := setupTestGCPServiceAccountIdentityCreds(ctx, t, stubToken)
|
||||
|
||||
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
||||
AuthInfo: &gcpTestAuthInfo{credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}},
|
||||
})
|
||||
|
||||
const numCalls = 5
|
||||
errs := make([]error, numCalls)
|
||||
var wg sync.WaitGroup
|
||||
for i := range numCalls {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
_, errs[idx] = creds.GetRequestMetadata(ctx)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, err := range errs {
|
||||
if err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||
t.Fatalf("Concurrent call to GetRequestMetadata() failed with err = %v, want error containing %q", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
if got := stubToken.getCallCount(); got != 1 {
|
||||
t.Fatalf("Unexpected call count to token provider: got %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Test verifies that credentials fail to return metadata when the security
|
||||
// level of the connection is not secure.
|
||||
func (s) TestGCPServiceAccountIdentityCallCreds_SecurityLevelFailure(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
stubToken := setupStubTokenProvider("token", nil)
|
||||
creds := setupTestGCPServiceAccountIdentityCreds(ctx, t, stubToken)
|
||||
|
||||
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
||||
AuthInfo: &gcpTestAuthInfo{credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}},
|
||||
})
|
||||
|
||||
const wantErr = "cannot send secure credentials on an insecure connection"
|
||||
_, err := creds.GetRequestMetadata(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||
t.Errorf("GetRequestMetadata() failed with error = %v, want error %q", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Test verifies that when a cached token is within the early expiration window
|
||||
// (stale but still valid), the second call to GetRequestMetadata immediately
|
||||
// returns the existing token and triggers an asynchronous background fetch for
|
||||
// a new token. A subsequent call, after waiting for the background fetch to
|
||||
// complete, returns the newly acquired token.
|
||||
func (s) TestGCPServiceAccountIdentityCallCreds_EarlyExpiry(t *testing.T) {
|
||||
const (
|
||||
firstToken = "token-A"
|
||||
secondToken = "token-B"
|
||||
)
|
||||
stubToken := setupStubTokenProvider(firstToken, nil)
|
||||
stubToken.setToken(
|
||||
&auth.Token{
|
||||
Value: firstToken,
|
||||
// Keeping it 1 minute as we subtract 30 seconds from the token expiry.
|
||||
Expiry: time.Now().Add(1 * time.Minute),
|
||||
},
|
||||
)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
creds := setupTestGCPServiceAccountIdentityCreds(ctx, t, stubToken)
|
||||
|
||||
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
||||
AuthInfo: &gcpTestAuthInfo{credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}},
|
||||
})
|
||||
|
||||
md, err := creds.GetRequestMetadata(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequestMetadata() failed: %v", err)
|
||||
}
|
||||
wantfirstToken := "Bearer " + firstToken
|
||||
if got := md["authorization"]; got != wantfirstToken {
|
||||
t.Errorf("GetRequestMetadata() returned %q, want %q", got, wantfirstToken)
|
||||
}
|
||||
|
||||
if got := stubToken.getCallCount(); got != 1 {
|
||||
t.Fatalf("Unexpected call count to token provider: got %d, want 1", got)
|
||||
}
|
||||
|
||||
// Update stub token provider to return a new token
|
||||
stubToken.setToken(&auth.Token{Value: secondToken, Expiry: time.Now().Add(1 * time.Hour)})
|
||||
|
||||
// The cached token has not expired yet but is stale (the current time is
|
||||
// past the preemptiveTokenRefresh window). GetRequestMetadata should
|
||||
// immediately return the cached first token to avoid blocking the RPC, and
|
||||
// asynchronously trigger a background fetch for the new token.
|
||||
md, err = creds.GetRequestMetadata(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequestMetadata() failed: %v", err)
|
||||
}
|
||||
if got := md["authorization"]; got != wantfirstToken {
|
||||
t.Errorf("GetRequestMetadata() returned %q, want %q", got, wantfirstToken)
|
||||
}
|
||||
|
||||
wantSecondToken := "Bearer " + secondToken
|
||||
|
||||
for ; ctx.Err() == nil; <-time.After(10 * time.Millisecond) {
|
||||
md, err = creds.GetRequestMetadata(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequestMetadata() failed: %v", err)
|
||||
}
|
||||
if md["authorization"] == wantSecondToken {
|
||||
// Verify that only 2 fetch calls are made.
|
||||
if got := stubToken.getCallCount(); got != 2 {
|
||||
t.Fatalf("Unexpected call count to token provider: got %d, want 2", got)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal("timed out waiting for background fetch to update token")
|
||||
}
|
||||
}
|
||||
|
||||
// Test verifies that different types of errors from the metadata server are
|
||||
// mapped to the correct gRPC status codes when returned through
|
||||
// GetRequestMetadata.
|
||||
func (s) TestGCPServiceAccountIdentityCallCreds_ErrorMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want codes.Code
|
||||
}{
|
||||
{
|
||||
name: "429_too_many_requests",
|
||||
err: &metadata.Error{Code: 429, Message: "too many requests"},
|
||||
want: codes.Unavailable,
|
||||
},
|
||||
{
|
||||
name: "503_service_unavailable",
|
||||
err: &metadata.Error{Code: 503, Message: "unavailable"},
|
||||
want: codes.Unavailable,
|
||||
},
|
||||
{
|
||||
name: "403_forbidden",
|
||||
err: &metadata.Error{Code: 403, Message: "forbidden"},
|
||||
want: codes.Unauthenticated,
|
||||
},
|
||||
{
|
||||
name: "generic_protocol_error",
|
||||
err: fmt.Errorf("generic connection error"),
|
||||
want: codes.Unavailable,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
stubToken := setupStubTokenProvider("token", nil)
|
||||
creds := setupTestGCPServiceAccountIdentityCreds(ctx, t, stubToken)
|
||||
stubToken.setErr(tc.err)
|
||||
|
||||
ctx = credentials.NewContextWithRequestInfo(ctx, credentials.RequestInfo{
|
||||
AuthInfo: &gcpTestAuthInfo{credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}},
|
||||
})
|
||||
|
||||
if _, err := creds.GetRequestMetadata(ctx); status.Code(err) != tc.want {
|
||||
t.Errorf("GetRequestMetadata() failed with gRPC status code %v, want %v for error %v", status.Code(err), tc.want, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
178
credentials/google/google.go
Normal file
178
credentials/google/google.go
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2018 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 defines credentials for google cloud services.
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/alts"
|
||||
"google.golang.org/grpc/credentials/oauth"
|
||||
"google.golang.org/grpc/grpclog"
|
||||
"google.golang.org/grpc/internal"
|
||||
)
|
||||
|
||||
const defaultCloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
|
||||
|
||||
var logger = grpclog.Component("credentials")
|
||||
|
||||
// DefaultCredentialsOptions constructs options to build DefaultCredentials.
|
||||
type DefaultCredentialsOptions struct {
|
||||
// PerRPCCreds is a per RPC credentials that is passed to a bundle.
|
||||
PerRPCCreds credentials.PerRPCCredentials
|
||||
// ALTSPerRPCCreds is a per RPC credentials that, if specified, will
|
||||
// supercede PerRPCCreds above for and only for ALTS connections.
|
||||
ALTSPerRPCCreds credentials.PerRPCCredentials
|
||||
}
|
||||
|
||||
// NewDefaultCredentialsWithOptions returns a credentials bundle that is
|
||||
// configured to work with google services.
|
||||
//
|
||||
// This API is experimental.
|
||||
func NewDefaultCredentialsWithOptions(opts DefaultCredentialsOptions) credentials.Bundle {
|
||||
if opts.PerRPCCreds == nil {
|
||||
var err error
|
||||
// If the ADC ends up being Compute Engine Credentials, this context
|
||||
// won't be used. Otherwise, the context dictates all the subsequent
|
||||
// token requests via HTTP. So we cannot have any deadline or timeout.
|
||||
opts.PerRPCCreds, err = newADC(context.TODO())
|
||||
if err != nil {
|
||||
logger.Warningf("NewDefaultCredentialsWithOptions: failed to create application oauth: %v", err)
|
||||
}
|
||||
}
|
||||
if opts.ALTSPerRPCCreds != nil {
|
||||
opts.PerRPCCreds = &dualPerRPCCreds{
|
||||
perRPCCreds: opts.PerRPCCreds,
|
||||
altsPerRPCCreds: opts.ALTSPerRPCCreds,
|
||||
}
|
||||
}
|
||||
c := &creds{opts: opts}
|
||||
bundle, err := c.NewWithMode(internal.CredsBundleModeFallback)
|
||||
if err != nil {
|
||||
logger.Warningf("NewDefaultCredentialsWithOptions: failed to create new creds: %v", err)
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
|
||||
// NewDefaultCredentials returns a credentials bundle that is configured to work
|
||||
// with google services.
|
||||
//
|
||||
// This API is experimental.
|
||||
func NewDefaultCredentials() credentials.Bundle {
|
||||
return NewDefaultCredentialsWithOptions(DefaultCredentialsOptions{})
|
||||
}
|
||||
|
||||
// NewComputeEngineCredentials returns a credentials bundle that is configured to work
|
||||
// with google services. This API must only be used when running on GCE. Authentication configured
|
||||
// by this API represents the GCE VM's default service account.
|
||||
//
|
||||
// This API is experimental.
|
||||
func NewComputeEngineCredentials() credentials.Bundle {
|
||||
return NewDefaultCredentialsWithOptions(DefaultCredentialsOptions{
|
||||
PerRPCCreds: oauth.NewComputeEngine(),
|
||||
})
|
||||
}
|
||||
|
||||
// creds implements credentials.Bundle.
|
||||
type creds struct {
|
||||
opts DefaultCredentialsOptions
|
||||
|
||||
// Supported modes are defined in internal/internal.go.
|
||||
mode string
|
||||
// The active transport credentials associated with this bundle.
|
||||
transportCreds credentials.TransportCredentials
|
||||
// The active per RPC credentials associated with this bundle.
|
||||
perRPCCreds credentials.PerRPCCredentials
|
||||
}
|
||||
|
||||
func (c *creds) TransportCredentials() credentials.TransportCredentials {
|
||||
return c.transportCreds
|
||||
}
|
||||
|
||||
func (c *creds) PerRPCCredentials() credentials.PerRPCCredentials {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return c.perRPCCreds
|
||||
}
|
||||
|
||||
var (
|
||||
newTLS = func() credentials.TransportCredentials {
|
||||
return credentials.NewTLS(nil)
|
||||
}
|
||||
newALTS = func() credentials.TransportCredentials {
|
||||
return alts.NewClientCreds(alts.DefaultClientOptions())
|
||||
}
|
||||
newADC = func(ctx context.Context) (credentials.PerRPCCredentials, error) {
|
||||
return oauth.NewApplicationDefault(ctx, defaultCloudPlatformScope)
|
||||
}
|
||||
)
|
||||
|
||||
// NewWithMode should make a copy of Bundle, and switch mode. Modifying the
|
||||
// existing Bundle may cause races.
|
||||
func (c *creds) NewWithMode(mode string) (credentials.Bundle, error) {
|
||||
newCreds := &creds{
|
||||
opts: c.opts,
|
||||
mode: mode,
|
||||
}
|
||||
|
||||
// Create transport credentials.
|
||||
switch mode {
|
||||
case internal.CredsBundleModeFallback:
|
||||
newCreds.transportCreds = newClusterTransportCreds(newTLS(), newALTS())
|
||||
case internal.CredsBundleModeBackendFromBalancer, internal.CredsBundleModeBalancer:
|
||||
// Only the clients can use google default credentials, so we only need
|
||||
// to create new ALTS client creds here.
|
||||
newCreds.transportCreds = newALTS()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported mode: %v", mode)
|
||||
}
|
||||
|
||||
if mode == internal.CredsBundleModeFallback || mode == internal.CredsBundleModeBackendFromBalancer {
|
||||
newCreds.perRPCCreds = newCreds.opts.PerRPCCreds
|
||||
}
|
||||
|
||||
return newCreds, nil
|
||||
}
|
||||
|
||||
// dualPerRPCCreds implements credentials.PerRPCCredentials by embedding the
|
||||
// fallback PerRPCCredentials and the ALTS one. It pickes one of them based on
|
||||
// the channel type.
|
||||
type dualPerRPCCreds struct {
|
||||
perRPCCreds credentials.PerRPCCredentials
|
||||
altsPerRPCCreds credentials.PerRPCCredentials
|
||||
}
|
||||
|
||||
func (d *dualPerRPCCreds) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
|
||||
ri, ok := credentials.RequestInfoFromContext(ctx)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("request info not found from context")
|
||||
}
|
||||
if authType := ri.AuthInfo.AuthType(); authType == "alts" {
|
||||
return d.altsPerRPCCreds.GetRequestMetadata(ctx, uri...)
|
||||
}
|
||||
// This ensures backward compatibility even if authType is not "tls".
|
||||
return d.perRPCCreds.GetRequestMetadata(ctx, uri...)
|
||||
}
|
||||
|
||||
func (d *dualPerRPCCreds) RequireTransportSecurity() bool {
|
||||
return d.altsPerRPCCreds.RequireTransportSecurity() || d.perRPCCreds.RequireTransportSecurity()
|
||||
}
|
||||
268
credentials/google/google_test.go
Normal file
268
credentials/google/google_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2021 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"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"google.golang.org/grpc/credentials"
|
||||
icredentials "google.golang.org/grpc/internal/credentials"
|
||||
"google.golang.org/grpc/internal/grpctest"
|
||||
"google.golang.org/grpc/internal/xds"
|
||||
"google.golang.org/grpc/resolver"
|
||||
)
|
||||
|
||||
var defaultTestTimeout = 10 * time.Second
|
||||
|
||||
type s struct {
|
||||
grpctest.Tester
|
||||
}
|
||||
|
||||
func Test(t *testing.T) {
|
||||
grpctest.RunSubTests(t, s{})
|
||||
}
|
||||
|
||||
type testCreds struct {
|
||||
credentials.TransportCredentials
|
||||
typ string
|
||||
}
|
||||
|
||||
func (c *testCreds) ClientHandshake(context.Context, string, net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return nil, &testAuthInfo{typ: c.typ}, nil
|
||||
}
|
||||
|
||||
func (c *testCreds) ServerHandshake(net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return nil, &testAuthInfo{typ: c.typ}, nil
|
||||
}
|
||||
|
||||
type testAuthInfo struct {
|
||||
typ string
|
||||
}
|
||||
|
||||
func (t *testAuthInfo) AuthType() string {
|
||||
return t.typ
|
||||
}
|
||||
|
||||
type testPerRPCCreds struct {
|
||||
md map[string]string
|
||||
}
|
||||
|
||||
func (c *testPerRPCCreds) RequireTransportSecurity() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *testPerRPCCreds) GetRequestMetadata(context.Context, ...string) (map[string]string, error) {
|
||||
return c.md, nil
|
||||
}
|
||||
|
||||
var (
|
||||
testTLS = &testCreds{typ: "tls"}
|
||||
testALTS = &testCreds{typ: "alts"}
|
||||
)
|
||||
|
||||
func overrideNewCredsFuncs() func() {
|
||||
origNewTLS := newTLS
|
||||
newTLS = func() credentials.TransportCredentials {
|
||||
return testTLS
|
||||
}
|
||||
origNewALTS := newALTS
|
||||
newALTS = func() credentials.TransportCredentials {
|
||||
return testALTS
|
||||
}
|
||||
origNewADC := newADC
|
||||
newADC = func(context.Context) (credentials.PerRPCCredentials, error) {
|
||||
// We do not use perRPC creds in this test. It is safe to return nil here.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return func() {
|
||||
newTLS = origNewTLS
|
||||
newALTS = origNewALTS
|
||||
newADC = origNewADC
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientHandshakeBasedOnClusterName that by default (without switching
|
||||
// modes), ClientHandshake does either tls or alts base on the cluster name in
|
||||
// attributes.
|
||||
func (s) TestClientHandshakeBasedOnClusterName(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
defer overrideNewCredsFuncs()()
|
||||
for bundleTyp, tc := range map[string]credentials.Bundle{
|
||||
"defaultCredsWithOptions": NewDefaultCredentialsWithOptions(DefaultCredentialsOptions{}),
|
||||
"defaultCreds": NewDefaultCredentials(),
|
||||
"computeCreds": NewComputeEngineCredentials(),
|
||||
} {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
wantTyp string
|
||||
}{
|
||||
{
|
||||
name: "no cluster name",
|
||||
ctx: ctx,
|
||||
wantTyp: "tls",
|
||||
},
|
||||
{
|
||||
name: "with non-CFE cluster name",
|
||||
ctx: icredentials.NewClientHandshakeInfoContext(ctx, credentials.ClientHandshakeInfo{
|
||||
Attributes: xds.SetXDSHandshakeClusterName(resolver.Address{}, "lalala").Attributes,
|
||||
}),
|
||||
// non-CFE backends should use alts.
|
||||
wantTyp: "alts",
|
||||
},
|
||||
{
|
||||
name: "with CFE cluster name",
|
||||
ctx: icredentials.NewClientHandshakeInfoContext(ctx, credentials.ClientHandshakeInfo{
|
||||
Attributes: xds.SetXDSHandshakeClusterName(resolver.Address{}, "google_cfe_bigtable.googleapis.com").Attributes,
|
||||
}),
|
||||
// CFE should use tls.
|
||||
wantTyp: "tls",
|
||||
},
|
||||
{
|
||||
name: "with xdstp CFE cluster name",
|
||||
ctx: icredentials.NewClientHandshakeInfoContext(ctx, credentials.ClientHandshakeInfo{
|
||||
Attributes: xds.SetXDSHandshakeClusterName(resolver.Address{}, "xdstp://traffic-director-c2p.xds.googleapis.com/envoy.config.cluster.v3.Cluster/google_cfe_bigtable.googleapis.com").Attributes,
|
||||
}),
|
||||
// CFE should use tls.
|
||||
wantTyp: "tls",
|
||||
},
|
||||
{
|
||||
name: "with xdstp non-CFE cluster name",
|
||||
ctx: icredentials.NewClientHandshakeInfoContext(ctx, credentials.ClientHandshakeInfo{
|
||||
Attributes: xds.SetXDSHandshakeClusterName(resolver.Address{}, "xdstp://other.com/envoy.config.cluster.v3.Cluster/google_cfe_bigtable.googleapis.com").Attributes,
|
||||
}),
|
||||
// non-CFE should use atls.
|
||||
wantTyp: "alts",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(bundleTyp+" "+tt.name, func(t *testing.T) {
|
||||
_, info, err := tc.TransportCredentials().ClientHandshake(tt.ctx, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ClientHandshake failed: %v", err)
|
||||
}
|
||||
if gotType := info.AuthType(); gotType != tt.wantTyp {
|
||||
t.Fatalf("unexpected authtype: %v, want: %v", gotType, tt.wantTyp)
|
||||
}
|
||||
|
||||
_, infoServer, err := tc.TransportCredentials().ServerHandshake(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ClientHandshake failed: %v", err)
|
||||
}
|
||||
// ServerHandshake should always do TLS.
|
||||
if gotType := infoServer.AuthType(); gotType != "tls" {
|
||||
t.Fatalf("unexpected server authtype: %v, want: %v", gotType, "tls")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCredentialsWithOptions(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
md1 := map[string]string{"foo": "tls"}
|
||||
md2 := map[string]string{"foo": "alts"}
|
||||
tests := []struct {
|
||||
desc string
|
||||
defaultCredsOpts DefaultCredentialsOptions
|
||||
authInfo credentials.AuthInfo
|
||||
wantedMetadata map[string]string
|
||||
}{
|
||||
{
|
||||
desc: "no ALTSPerRPCCreds with tls channel",
|
||||
defaultCredsOpts: DefaultCredentialsOptions{
|
||||
PerRPCCreds: &testPerRPCCreds{
|
||||
md: md1,
|
||||
},
|
||||
},
|
||||
authInfo: &testAuthInfo{typ: "tls"},
|
||||
wantedMetadata: md1,
|
||||
},
|
||||
{
|
||||
desc: "no ALTSPerRPCCreds with alts channel",
|
||||
defaultCredsOpts: DefaultCredentialsOptions{
|
||||
PerRPCCreds: &testPerRPCCreds{
|
||||
md: md1,
|
||||
},
|
||||
},
|
||||
authInfo: &testAuthInfo{typ: "alts"},
|
||||
wantedMetadata: md1,
|
||||
},
|
||||
{
|
||||
desc: "ALTSPerRPCCreds specified with tls channel",
|
||||
defaultCredsOpts: DefaultCredentialsOptions{
|
||||
PerRPCCreds: &testPerRPCCreds{
|
||||
md: md1,
|
||||
},
|
||||
ALTSPerRPCCreds: &testPerRPCCreds{
|
||||
md: md2,
|
||||
},
|
||||
},
|
||||
authInfo: &testAuthInfo{typ: "tls"},
|
||||
wantedMetadata: md1,
|
||||
},
|
||||
{
|
||||
desc: "ALTSPerRPCCreds specified with alts channel",
|
||||
defaultCredsOpts: DefaultCredentialsOptions{
|
||||
PerRPCCreds: &testPerRPCCreds{
|
||||
md: md1,
|
||||
},
|
||||
ALTSPerRPCCreds: &testPerRPCCreds{
|
||||
md: md2,
|
||||
},
|
||||
},
|
||||
authInfo: &testAuthInfo{typ: "alts"},
|
||||
wantedMetadata: md2,
|
||||
},
|
||||
{
|
||||
desc: "ALTSPerRPCCreds specified with unknown channel",
|
||||
defaultCredsOpts: DefaultCredentialsOptions{
|
||||
PerRPCCreds: &testPerRPCCreds{
|
||||
md: md1,
|
||||
},
|
||||
ALTSPerRPCCreds: &testPerRPCCreds{
|
||||
md: md2,
|
||||
},
|
||||
},
|
||||
authInfo: &testAuthInfo{typ: "foo"},
|
||||
wantedMetadata: md1,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
bundle := NewDefaultCredentialsWithOptions(tc.defaultCredsOpts)
|
||||
ri := credentials.RequestInfo{AuthInfo: tc.authInfo}
|
||||
ctx := credentials.NewContextWithRequestInfo(ctx, ri)
|
||||
got, err := bundle.PerRPCCredentials().GetRequestMetadata(ctx, "uri")
|
||||
if err != nil {
|
||||
t.Fatalf("Bundle's PerRPCCredentials().GetRequestMetadata() unexpected error = %v", err)
|
||||
}
|
||||
if diff := cmp.Diff(got, tc.wantedMetadata); diff != "" {
|
||||
t.Errorf("Unexpected request metadata from bundle's PerRPCCredentials. Diff (-got +want):\n%v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
35
credentials/google/internal/internal.go
Normal file
35
credentials/google/internal/internal.go
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
*
|
||||
* 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 internal contains functionality internal to the google package.
|
||||
package internal
|
||||
|
||||
import (
|
||||
"cloud.google.com/go/auth"
|
||||
"cloud.google.com/go/auth/credentials/idtoken"
|
||||
"google.golang.org/grpc/internal/backoff"
|
||||
)
|
||||
|
||||
// The following variables are overridden in tests.
|
||||
var (
|
||||
// BackoffStrategy is the backoff strategy to use when token fetch fails.
|
||||
BackoffStrategy backoff.Strategy
|
||||
|
||||
// NewIDTokenCredentials builds idtoken credentials using specified options.
|
||||
NewIDTokenCredentials func(opts *idtoken.Options) (*auth.Credentials, error)
|
||||
)
|
||||
128
credentials/google/xds.go
Normal file
128
credentials/google/xds.go
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2021 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"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/internal/xds"
|
||||
)
|
||||
|
||||
const cfeClusterNamePrefix = "google_cfe_"
|
||||
const cfeClusterResourceNamePrefix = "/envoy.config.cluster.v3.Cluster/google_cfe_"
|
||||
const cfeClusterAuthorityName = "traffic-director-c2p.xds.googleapis.com"
|
||||
|
||||
// clusterTransportCreds is a combo of TLS + ALTS.
|
||||
//
|
||||
// On the client, ClientHandshake picks TLS or ALTS based on address attributes.
|
||||
// - if attributes has cluster name
|
||||
// - if cluster name has prefix "google_cfe_", or
|
||||
// "xdstp://traffic-director-c2p.xds.googleapis.com/envoy.config.cluster.v3.Cluster/google_cfe_",
|
||||
// use TLS
|
||||
// - otherwise, use ALTS
|
||||
//
|
||||
// - else, do TLS
|
||||
//
|
||||
// On the server, ServerHandshake always does TLS.
|
||||
type clusterTransportCreds struct {
|
||||
tls credentials.TransportCredentials
|
||||
alts credentials.TransportCredentials
|
||||
}
|
||||
|
||||
func newClusterTransportCreds(tls, alts credentials.TransportCredentials) *clusterTransportCreds {
|
||||
return &clusterTransportCreds{
|
||||
tls: tls,
|
||||
alts: alts,
|
||||
}
|
||||
}
|
||||
|
||||
// clusterName returns the xDS cluster name stored in the attributes in the
|
||||
// context.
|
||||
func clusterName(ctx context.Context) string {
|
||||
chi := credentials.ClientHandshakeInfoFromContext(ctx)
|
||||
if chi.Attributes == nil {
|
||||
return ""
|
||||
}
|
||||
cluster, _ := xds.GetXDSHandshakeClusterName(chi.Attributes)
|
||||
return cluster
|
||||
}
|
||||
|
||||
// isDirectPathCluster returns true if the cluster in the context is a
|
||||
// directpath cluster, meaning ALTS should be used.
|
||||
func isDirectPathCluster(ctx context.Context) bool {
|
||||
cluster := clusterName(ctx)
|
||||
if cluster == "" {
|
||||
// No cluster; not xDS; use TLS.
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(cluster, cfeClusterNamePrefix) {
|
||||
// xDS cluster prefixed by "google_cfe_"; use TLS.
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(cluster, "xdstp:") {
|
||||
// Other xDS cluster name; use ALTS.
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(cluster)
|
||||
if err != nil {
|
||||
// Shouldn't happen, but assume ALTS.
|
||||
return true
|
||||
}
|
||||
// If authority AND path match our CFE checks, use TLS; otherwise use ALTS.
|
||||
return u.Host != cfeClusterAuthorityName || !strings.HasPrefix(u.Path, cfeClusterResourceNamePrefix)
|
||||
}
|
||||
|
||||
func (c *clusterTransportCreds) ClientHandshake(ctx context.Context, authority string, rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
if isDirectPathCluster(ctx) {
|
||||
// If attributes have cluster name, and cluster name is not cfe, it's a
|
||||
// backend address, use ALTS.
|
||||
return c.alts.ClientHandshake(ctx, authority, rawConn)
|
||||
}
|
||||
return c.tls.ClientHandshake(ctx, authority, rawConn)
|
||||
}
|
||||
|
||||
func (c *clusterTransportCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return c.tls.ServerHandshake(conn)
|
||||
}
|
||||
|
||||
func (c *clusterTransportCreds) Info() credentials.ProtocolInfo {
|
||||
// TODO: this always returns tls.Info now, because we don't have a cluster
|
||||
// name to check when this method is called. This method doesn't affect
|
||||
// anything important now. We may want to revisit this if it becomes more
|
||||
// important later.
|
||||
return c.tls.Info()
|
||||
}
|
||||
|
||||
func (c *clusterTransportCreds) Clone() credentials.TransportCredentials {
|
||||
return &clusterTransportCreds{
|
||||
tls: c.tls.Clone(),
|
||||
alts: c.alts.Clone(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *clusterTransportCreds) OverrideServerName(s string) error {
|
||||
if err := c.tls.OverrideServerName(s); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.alts.OverrideServerName(s)
|
||||
}
|
||||
60
credentials/google/xds_test.go
Normal file
60
credentials/google/xds_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2021 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"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
icredentials "google.golang.org/grpc/internal/credentials"
|
||||
"google.golang.org/grpc/internal/xds"
|
||||
"google.golang.org/grpc/resolver"
|
||||
)
|
||||
|
||||
func (s) TestIsDirectPathCluster(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
c := func(cluster string) context.Context {
|
||||
return icredentials.NewClientHandshakeInfoContext(ctx, credentials.ClientHandshakeInfo{
|
||||
Attributes: xds.SetXDSHandshakeClusterName(resolver.Address{}, cluster).Attributes,
|
||||
})
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
want bool
|
||||
}{
|
||||
{"not an xDS cluster", ctx, false},
|
||||
{"cfe", c("google_cfe_bigtable.googleapis.com"), false},
|
||||
{"non-cfe", c("google_bigtable.googleapis.com"), true},
|
||||
{"starts with xdstp but not cfe format", c("xdstp:google_cfe_bigtable.googleapis.com"), true},
|
||||
{"no authority", c("xdstp:///envoy.config.cluster.v3.Cluster/google_cfe_"), true},
|
||||
{"wrong authority", c("xdstp://foo.bar/envoy.config.cluster.v3.Cluster/google_cfe_"), true},
|
||||
{"xdstp CFE", c("xdstp://traffic-director-c2p.xds.googleapis.com/envoy.config.cluster.v3.Cluster/google_cfe_"), false},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isDirectPathCluster(tc.ctx); got != tc.want {
|
||||
t.Errorf("isDirectPathCluster(_) = %v; want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user