| /* |
| * |
| * Copyright 2025 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 jwt |
| |
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "sync" |
| "time" |
| |
| "google.golang.org/grpc/codes" |
| "google.golang.org/grpc/credentials" |
| "google.golang.org/grpc/internal/backoff" |
| "google.golang.org/grpc/status" |
| ) |
| |
| const preemptiveRefreshThreshold = time.Minute |
| |
| // jwtTokenFileCallCreds provides JWT token-based PerRPCCredentials that reads |
| // tokens from a file. |
| // This implementation follows the A97 JWT Call Credentials specification. |
| type jwtTokenFileCallCreds struct { |
| fileReader *jwtFileReader |
| backoffStrategy backoff.Strategy |
| |
| // cached data protected by mu |
| mu sync.Mutex |
| cachedAuthHeader string // "Bearer " + token |
| cachedExpiry time.Time // Slightly less than actual expiration time |
| cachedError error // Error from last failed attempt |
| retryAttempt int // Current retry attempt number |
| nextRetryTime time.Time // When next retry is allowed |
| pendingRefresh bool // Whether a refresh is currently in progress |
| } |
| |
| // NewTokenFileCallCredentials creates PerRPCCredentials that reads JWT tokens |
| // from the specified file path. |
| func NewTokenFileCallCredentials(tokenFilePath string) (credentials.PerRPCCredentials, error) { |
| if tokenFilePath == "" { |
| return nil, fmt.Errorf("tokenFilePath cannot be empty") |
| } |
| |
| creds := &jwtTokenFileCallCreds{ |
| fileReader: &jwtFileReader{tokenFilePath: tokenFilePath}, |
| backoffStrategy: backoff.DefaultExponential, |
| } |
| |
| return creds, nil |
| } |
| |
| // GetRequestMetadata gets the current request metadata, refreshing tokens if |
| // required. This implementation follows the PerRPCCredentials interface. The |
| // tokens will get automatically refreshed if they are about to expire or if |
| // they haven't been loaded successfully yet. |
| // If it's not possible to extract a token from the file, UNAVAILABLE is |
| // returned. |
| // If the token is extracted but invalid, then UNAUTHENTICATED is returned. |
| // If errors are encoutered, a backoff is applied before retrying. |
| func (c *jwtTokenFileCallCreds) 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("cannot send secure credentials on an insecure connection: %v", err) |
| } |
| |
| c.mu.Lock() |
| defer c.mu.Unlock() |
| |
| if c.isTokenValidLocked() { |
| needsPreemptiveRefresh := time.Until(c.cachedExpiry) < preemptiveRefreshThreshold |
| if needsPreemptiveRefresh && !c.pendingRefresh { |
| // Start refresh if not pending (handling the prior RPC may have |
| // just spawned a goroutine). |
| c.pendingRefresh = true |
| go c.refreshToken() |
| } |
| return map[string]string{ |
| "authorization": c.cachedAuthHeader, |
| }, nil |
| } |
| |
| // If in backoff state, just return the cached error. |
| if c.cachedError != nil && time.Now().Before(c.nextRetryTime) { |
| return nil, c.cachedError |
| } |
| |
| // At this point, the token is either invalid or expired and we are no |
| // longer backing off from any encountered errors. So refresh it. |
| // NB: We are holding the lock while reading the token from file. This will |
| // cause other RPCs to block until the read completes (sucecssfully or not) |
| // and the cache is updated. Subsequent RPCs will end up using the cache. |
| // This is per A97. |
| token, expiry, err := c.fileReader.readToken() |
| c.updateCacheLocked(token, expiry, err) |
| |
| if c.cachedError != nil { |
| return nil, c.cachedError |
| } |
| return map[string]string{ |
| "authorization": c.cachedAuthHeader, |
| }, nil |
| } |
| |
| // RequireTransportSecurity indicates whether the credentials requires |
| // transport security. |
| func (c *jwtTokenFileCallCreds) RequireTransportSecurity() bool { |
| return true |
| } |
| |
| // isTokenValidLocked checks if the cached token is still valid. |
| // Caller must hold c.mu lock. |
| func (c *jwtTokenFileCallCreds) isTokenValidLocked() bool { |
| if c.cachedAuthHeader == "" { |
| return false |
| } |
| return c.cachedExpiry.After(time.Now()) |
| } |
| |
| // refreshToken reads the token from file and updates the cached data. |
| func (c *jwtTokenFileCallCreds) refreshToken() { |
| // Deliberately not locking c.mu here. This way other RPCs can proceed |
| // while we read the token. This is per gRFC A97. |
| token, expiry, err := c.fileReader.readToken() |
| |
| c.mu.Lock() |
| defer c.mu.Unlock() |
| c.updateCacheLocked(token, expiry, err) |
| c.pendingRefresh = false |
| } |
| |
| // updateCacheLocked updates the cached token, expiry, and error state. |
| // If an error is provided, it determines whether to set it as an UNAVAILABLE |
| // or UNAUTHENTICATED error based on the error type. |
| // NOTE: This method (and its callers) do not queue up a token refresh/retry if |
| // the expiration is soon / an error was encountered. Instead, this is done when |
| // handling RPCs. This is as per gRFC A97, which states that it is |
| // undesirable to retry loading the token if the channel is idle. |
| // Caller must hold c.mu lock. |
| func (c *jwtTokenFileCallCreds) updateCacheLocked(token string, expiry time.Time, err error) { |
| if err != nil { |
| // Convert to gRPC status codes |
| if errors.Is(err, errTokenFileAccess) { |
| c.cachedError = status.Error(codes.Unavailable, err.Error()) |
| } else if errors.Is(err, errJWTValidation) { |
| c.cachedError = status.Error(codes.Unauthenticated, err.Error()) |
| } else { |
| // Should not happen. Treat unknown errors as UNAUTHENTICATED. |
| c.cachedError = status.Error(codes.Unauthenticated, err.Error()) |
| } |
| c.retryAttempt++ |
| backoffDelay := c.backoffStrategy.Backoff(c.retryAttempt - 1) |
| c.nextRetryTime = time.Now().Add(backoffDelay) |
| return |
| } |
| // Success - clear any cached error and update token cache |
| c.cachedError = nil |
| c.retryAttempt = 0 |
| c.nextRetryTime = time.Time{} |
| |
| c.cachedAuthHeader = "Bearer " + token |
| // Per gRFC A97: consider token invalid if it expires within the next 30 |
| // seconds to accommodate for clock skew and server processing time. |
| c.cachedExpiry = expiry.Add(-30 * time.Second) |
| } |