-
Notifications
You must be signed in to change notification settings - Fork 4.6k
credentials: implement file-based JWT Call Credentials (part 1 for A97) #8431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
63 commits
Select commit
Hold shift + click to select a range
6e63daa
xds: read JWT credentials from file as per A97
dimpavloff 3268ea5
remove example
dimpavloff b18a1f5
refactor test creation
dimpavloff eb391af
refactor token string padding
dimpavloff d43893a
remove example; mark as experimental
dimpavloff 167b86e
reorganise struct attributes
dimpavloff 439d28c
rename methods with Locked suffix
dimpavloff b36d4b6
remove context param from refreshTokenSync
dimpavloff 26e0451
reformat comments; remove redundant cachedErrorTime field
dimpavloff da2de8c
add defaultTestTimeout const
dimpavloff 51ce34c
refactor test to use wantErr string only
dimpavloff f87f1f2
fix punctuation
dimpavloff 15dd057
less prosaic subtest names
dimpavloff 54cbbcb
remove unit test
dimpavloff 9c5035d
rename preemptiveRefresh to forceRefresh
dimpavloff ec915dc
remove unused context param
dimpavloff 1d95fa2
rename files
dimpavloff a797ed9
use cond variable
dimpavloff fd388d1
refactor to no longer need cond
dimpavloff 790a2d9
fix docstring comment
dimpavloff 6713190
cache authorization header instead of token
dimpavloff 3f563eb
remove internal/ and xds/ changes
dimpavloff a38573b
remove xds/bootstrap
dimpavloff 12fedd5
fix comment docstrings
dimpavloff 52445c7
remove newJWTFileReader
dimpavloff 1678016
make ReadToken private method
dimpavloff 1be843b
use subtests
dimpavloff 8ac3296
use writeTempFile
dimpavloff b0bdc70
add comment about RPC queue behaviour
dimpavloff e4f955c
remove needsPreemptiveRefreshLocked method
dimpavloff f78178c
split NewTokenFileCallCredentials tests
dimpavloff bbeb759
remove leftover os.MkdirTemp
dimpavloff bba5d34
remove audience parameter and do not set it at all for test tokens
dimpavloff 607868b
test for grpc codes in TestTokenFileCallCreds_GetRequestMetadata
dimpavloff bc2d327
use cmp.Diff in TestTokenFileCallCreds_TokenCaching
dimpavloff 330d9a8
fix createTestJWT docstring
dimpavloff 774d83e
refactor readToken() and tests to use error values
dimpavloff b9dcfcb
remove errJWTFormat in favour of validation error
dimpavloff 6ee5ba7
error wrapping
dimpavloff 14c5ccd
subtests with underscores only
dimpavloff ca8227d
change credentials.CheckSecurityLevel error mgs; success path identation
dimpavloff ff50123
re-order assertions
dimpavloff c05da9f
remove string comparisons
dimpavloff 3f9195e
add TODO to tests
dimpavloff 2c9a06d
rename jWTFileReader to jwtFileReader
dimpavloff 42c6804
move error wrapping
dimpavloff f1a1cd3
remove leftover package docstring
dimpavloff 2b8ae01
use RawURLEncoding.DecodeString
dimpavloff 1b5a609
%v instead of %w in credentials.CheckSecurityLevel error string
dimpavloff 4c30c68
single check for preemptive refresh
dimpavloff 0c15d73
clarify why lock is not used and document concurrent calls for jwtFil…
dimpavloff 7d5f578
rename test suite function name
dimpavloff c8852fc
trailing brace in comment
dimpavloff 0be0243
add comments to clarify we do not trigger refresh on updating the cac…
dimpavloff 36042db
shouldTriggerRefresh failure message update
dimpavloff 7e50e3e
re-use err instead of err1,2,3,4,5
dimpavloff 8e3b91b
improve err==nil failure message in test
dimpavloff 4ac6f4c
t.Fatal and t.Error message capitalisation where possible
dimpavloff e83fbee
combine t.Error into a single t.Fatal and indent
dimpavloff 75fbc02
attempt to make the token referesh retry backoff test more readable
dimpavloff eae450a
rename function, omit zero value param, formatting
dimpavloff 3b651c2
strip jwt_ prefix from filenames
dimpavloff 4336d04
use strings.Cut to extract claims
dimpavloff File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| /* | ||
| * | ||
| * 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 implements JWT token file-based call credentials. | ||
| // | ||
| // This package provides support for A97 JWT Call Credentials, allowing gRPC | ||
| // clients to authenticate using JWT tokens read from files. While originally | ||
| // designed for xDS environments, these credentials are general-purpose. | ||
| // | ||
| // # Usage | ||
| // | ||
| // The credentials can be used directly: | ||
| // | ||
| // import "google.golang.org/grpc/credentials/jwt" | ||
| // | ||
| // creds, err := jwt.NewTokenFileCallCredentials("/path/to/jwt.token") | ||
| // if err != nil { | ||
| // log.Fatal(err) | ||
| // } | ||
| // | ||
| // conn, err := grpc.NewClient("example.com:443", grpc.WithPerRPCCredentials(creds)) | ||
| // | ||
| // Or configured via xDS bootstrap file; see grpc/xds/bootstrap for details. | ||
| // | ||
| // # Token Requirements | ||
| // | ||
| // JWT tokens must: | ||
| // - Be valid, well-formed JWT tokens with header, payload, and signature | ||
| // - Include an "exp" (expiration) claim | ||
| // - Be readable from the specified file path | ||
| // | ||
| // # Considerations | ||
| // | ||
| // - Tokens are cached until expiration to avoid excessive file I/O | ||
| // - Transport security is required (RequireTransportSecurity returns true) | ||
| // - Errors in reading tokens or parsing JWTs will result in RPC UNAVAILALBE or UNAUTHENTICATED errors | ||
| // - These errors are cached and retried with exponential backoff. | ||
| // | ||
| // This implementation is originally intended for use in service mesh | ||
| // environments like Istio where JWT tokens are provisioned and rotated by the | ||
| // infrastructure. | ||
| package jwt | ||
easwars marked this conversation as resolved.
Show resolved
Hide resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,285 @@ | ||
| /* | ||
| * | ||
| * 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 implements gRPC credentials using JWT tokens from files. | ||
| package jwt | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "fmt" | ||
| "os" | ||
| "strings" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "google.golang.org/grpc/codes" | ||
| "google.golang.org/grpc/credentials" | ||
| "google.golang.org/grpc/internal/backoff" | ||
| "google.golang.org/grpc/status" | ||
| ) | ||
|
|
||
| // jwtClaims represents the JWT claims structure for extracting expiration time. | ||
| type jwtClaims struct { | ||
| Exp int64 `json:"exp"` | ||
| } | ||
|
|
||
| // jwtTokenFileCallCreds provides JWT token-based PerRPCCredentials that reads | ||
| // tokens from a file. | ||
| // This implementation follows the A97 JWT Call Credentials specification. | ||
| type jwtTokenFileCallCreds struct { | ||
| tokenFilePath string | ||
|
|
||
| // Cached token data | ||
| mu sync.RWMutex | ||
| cachedToken string | ||
| cachedExpiration time.Time // Slightly reduced expiration time compared to the actual exp | ||
|
|
||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| // Error caching with backoff | ||
| cachedError error // Cached error from last failed attempt | ||
| cachedErrorTime time.Time // When the error was cached | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| backoffStrategy backoff.Strategy // Backoff strategy when error occurs | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| retryAttempt int // Current retry attempt number | ||
| nextRetryTime time.Time // When next retry is allowed | ||
|
|
||
| // Pre-emptive refresh mutex | ||
| refreshMu sync.Mutex | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // NewTokenFileCallCredentials creates PerRPCCredentials that reads JWT tokens | ||
| // from the specified file path. | ||
| // | ||
| // tokenFilePath is the filepath to the JWT token file. | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| func NewTokenFileCallCredentials(tokenFilePath string) (credentials.PerRPCCredentials, error) { | ||
| if tokenFilePath == "" { | ||
| return nil, fmt.Errorf("tokenFilePath cannot be empty") | ||
| } | ||
|
|
||
| return &jwtTokenFileCallCreds{ | ||
| tokenFilePath: tokenFilePath, | ||
| backoffStrategy: backoff.DefaultExponential, | ||
| }, 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("unable to transfer JWT token file PerRPCCredentials: %v", err) | ||
| } | ||
|
|
||
| // this may be delayed if the token needs to be refreshed from file | ||
| token, err := c.getToken(ctx) | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return map[string]string{ | ||
| "authorization": "Bearer " + token, | ||
| }, nil | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // RequireTransportSecurity indicates whether the credentials requires | ||
| // transport security. | ||
| func (c *jwtTokenFileCallCreds) RequireTransportSecurity() bool { | ||
| return true | ||
| } | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| // getToken returns a valid JWT token, reading from file if necessary. | ||
| // Implements pre-emptive refresh and caches errors with backoff. | ||
| func (c *jwtTokenFileCallCreds) getToken(ctx context.Context) (string, error) { | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| c.mu.RLock() | ||
|
|
||
| if c.isTokenValid() { | ||
| token := c.cachedToken | ||
| shouldRefresh := c.needsPreemptiveRefresh() | ||
| c.mu.RUnlock() | ||
|
|
||
| if shouldRefresh { | ||
| c.triggerPreemptiveRefresh() | ||
| } | ||
| return token, nil | ||
| } | ||
|
|
||
| // if still within backoff period, return cached error to avoid repeated file reads | ||
| if c.cachedError != nil && time.Now().Before(c.nextRetryTime) { | ||
| err := c.cachedError | ||
| c.mu.RUnlock() | ||
| return "", err | ||
| } | ||
|
|
||
| c.mu.RUnlock() | ||
| // Token is expired or missing or the retry backoff period has expired. So | ||
| // refresh synchronously. | ||
| // NOTE: refreshTokenSync itself acquires the write lock | ||
| return c.refreshTokenSync(ctx, false) | ||
| } | ||
|
|
||
| // isTokenValid checks if the cached token is still valid. | ||
| // Caller must hold c.mu.RLock(). | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| func (c *jwtTokenFileCallCreds) isTokenValid() bool { | ||
| if c.cachedToken == "" { | ||
| return false | ||
| } | ||
| return c.cachedExpiration.After(time.Now()) | ||
| } | ||
|
|
||
| // needsPreemptiveRefresh checks if a pre-emptive refresh should be triggered. | ||
| // Returns true if the cached token is valid but expires within 1 minute. | ||
| // We only trigger pre-emptive refresh for valid tokens - if the token is invalid | ||
| // or expired, the next RPC will handle synchronous refresh instead. | ||
| // Caller must hold c.mu.RLock(). | ||
| func (c *jwtTokenFileCallCreds) needsPreemptiveRefresh() bool { | ||
| return c.isTokenValid() && time.Until(c.cachedExpiration) < time.Minute | ||
| } | ||
|
|
||
| // triggerPreemptiveRefresh starts a background refresh if needed. | ||
| // Multiple concurrent calls are safe - only one refresh will run at a time. | ||
| // The refresh runs in a separate goroutine and does not block the caller. | ||
| func (c *jwtTokenFileCallCreds) triggerPreemptiveRefresh() { | ||
| go func() { | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| c.refreshMu.Lock() | ||
| defer c.refreshMu.Unlock() | ||
|
|
||
| // Re-check if refresh is still needed under mutex | ||
| c.mu.RLock() | ||
| stillNeeded := c.needsPreemptiveRefresh() | ||
| c.mu.RUnlock() | ||
|
|
||
| if !stillNeeded { | ||
| return // Another goroutine already refreshed or token expired | ||
| } | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| // Force refresh to read new token even if current one is still valid | ||
| _, _ = c.refreshTokenSync(ctx, true) | ||
| }() | ||
| } | ||
|
|
||
| // refreshTokenSync reads a new token from the file and updates the cache. If | ||
| // preemptiveRefresh is true, bypasses the validity check of the currently cached | ||
| // token and always reads from file. | ||
| // This is used for pre-emptive refresh to ensure new tokens are loaded even when | ||
| // the cached token is still valid. If preemptiveRefresh is false, skips file read | ||
| // when cached token is still valid, optimizing concurrent synchronous refresh calls | ||
| // where one RPC may have already updated the cache while another was waiting on the lock. | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| func (c *jwtTokenFileCallCreds) refreshTokenSync(_ context.Context, preemptiveRefresh bool) (string, error) { | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
|
|
||
| // Double-check under write lock but skip if preemptive refresh is requested | ||
| if !preemptiveRefresh && c.isTokenValid() { | ||
easwars marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return c.cachedToken, nil | ||
| } | ||
|
|
||
| tokenBytes, err := os.ReadFile(c.tokenFilePath) | ||
| if err != nil { | ||
| err = status.Errorf(codes.Unavailable, "failed to read token file %q: %v", c.tokenFilePath, err) | ||
| c.setErrorWithBackoff(err) | ||
| return "", err | ||
| } | ||
|
|
||
| token := strings.TrimSpace(string(tokenBytes)) | ||
| if token == "" { | ||
| err := status.Errorf(codes.Unavailable, "token file %q is empty", c.tokenFilePath) | ||
| c.setErrorWithBackoff(err) | ||
| return "", err | ||
| } | ||
|
|
||
| // Parse JWT to extract expiration | ||
| exp, err := c.extractExpiration(token) | ||
| if err != nil { | ||
| err = status.Errorf(codes.Unauthenticated, "failed to parse JWT from token file %q: %v", c.tokenFilePath, err) | ||
| c.setErrorWithBackoff(err) | ||
| return "", err | ||
| } | ||
|
|
||
| // Success - clear any cached error and backoff state, update token cache | ||
| c.clearErrorAndBackoff() | ||
| c.cachedToken = token | ||
| // Per RFC A97: consider token invalid if it expires within the next 30 | ||
| // seconds to accommodate for clock skew and server processing time. | ||
| c.cachedExpiration = exp.Add(-30 * time.Second) | ||
|
|
||
| return token, nil | ||
| } | ||
|
|
||
| // extractExpiration parses the JWT token to extract the expiration time. | ||
| func (c *jwtTokenFileCallCreds) extractExpiration(token string) (time.Time, error) { | ||
| parts := strings.Split(token, ".") | ||
| if len(parts) != 3 { | ||
| return time.Time{}, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) | ||
| } | ||
|
|
||
| payload := parts[1] | ||
| // Add padding if necessary for base64 decoding | ||
| if m := len(payload) % 4; m != 0 { | ||
| payload += strings.Repeat("=", 4-m) | ||
| } | ||
|
|
||
| payloadBytes, err := base64.URLEncoding.DecodeString(payload) | ||
| if err != nil { | ||
| return time.Time{}, fmt.Errorf("failed to decode JWT payload: %v", err) | ||
| } | ||
|
|
||
| var claims jwtClaims | ||
| if err := json.Unmarshal(payloadBytes, &claims); err != nil { | ||
| return time.Time{}, fmt.Errorf("failed to unmarshal JWT claims: %v", err) | ||
| } | ||
|
|
||
| if claims.Exp == 0 { | ||
| return time.Time{}, fmt.Errorf("JWT token has no expiration claim") | ||
| } | ||
|
|
||
| expTime := time.Unix(claims.Exp, 0) | ||
|
|
||
| // Check if token is already expired | ||
| if expTime.Before(time.Now()) { | ||
| return time.Time{}, fmt.Errorf("JWT token is expired") | ||
| } | ||
|
|
||
| return expTime, nil | ||
| } | ||
|
|
||
| // setErrorWithBackoff caches an error and calculates the next retry time using exponential backoff. | ||
| // Caller must hold c.mu write lock. | ||
| func (c *jwtTokenFileCallCreds) setErrorWithBackoff(err error) { | ||
| c.cachedError = err | ||
| c.cachedErrorTime = time.Now() | ||
| c.retryAttempt++ | ||
| backoffDelay := c.backoffStrategy.Backoff(c.retryAttempt - 1) | ||
| c.nextRetryTime = time.Now().Add(backoffDelay) | ||
| } | ||
|
|
||
| // clearErrorAndBackoff clears the cached error and resets backoff state. | ||
| // Caller must hold c.mu write lock. | ||
| func (c *jwtTokenFileCallCreds) clearErrorAndBackoff() { | ||
| c.cachedError = nil | ||
| c.cachedErrorTime = time.Time{} | ||
| c.retryAttempt = 0 | ||
| c.nextRetryTime = time.Time{} | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.