Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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 Jun 14, 2025
3268ea5
remove example
dimpavloff Jul 6, 2025
b18a1f5
refactor test creation
dimpavloff Jul 6, 2025
eb391af
refactor token string padding
dimpavloff Jul 26, 2025
d43893a
remove example; mark as experimental
dimpavloff Jul 29, 2025
167b86e
reorganise struct attributes
dimpavloff Jul 29, 2025
439d28c
rename methods with Locked suffix
dimpavloff Jul 29, 2025
b36d4b6
remove context param from refreshTokenSync
dimpavloff Jul 29, 2025
26e0451
reformat comments; remove redundant cachedErrorTime field
dimpavloff Jul 29, 2025
da2de8c
add defaultTestTimeout const
dimpavloff Jul 29, 2025
51ce34c
refactor test to use wantErr string only
dimpavloff Jul 29, 2025
f87f1f2
fix punctuation
dimpavloff Jul 29, 2025
15dd057
less prosaic subtest names
dimpavloff Jul 29, 2025
54cbbcb
remove unit test
dimpavloff Jul 30, 2025
9c5035d
rename preemptiveRefresh to forceRefresh
dimpavloff Jul 31, 2025
ec915dc
remove unused context param
dimpavloff Jul 31, 2025
1d95fa2
rename files
dimpavloff Aug 21, 2025
a797ed9
use cond variable
dimpavloff Aug 21, 2025
fd388d1
refactor to no longer need cond
dimpavloff Aug 21, 2025
790a2d9
fix docstring comment
dimpavloff Aug 21, 2025
6713190
cache authorization header instead of token
dimpavloff Aug 21, 2025
3f563eb
remove internal/ and xds/ changes
dimpavloff Aug 21, 2025
a38573b
remove xds/bootstrap
dimpavloff Aug 21, 2025
12fedd5
fix comment docstrings
dimpavloff Aug 21, 2025
52445c7
remove newJWTFileReader
dimpavloff Aug 26, 2025
1678016
make ReadToken private method
dimpavloff Aug 26, 2025
1be843b
use subtests
dimpavloff Aug 26, 2025
8ac3296
use writeTempFile
dimpavloff Aug 26, 2025
b0bdc70
add comment about RPC queue behaviour
dimpavloff Aug 26, 2025
e4f955c
remove needsPreemptiveRefreshLocked method
dimpavloff Aug 26, 2025
f78178c
split NewTokenFileCallCredentials tests
dimpavloff Aug 26, 2025
bbeb759
remove leftover os.MkdirTemp
dimpavloff Aug 26, 2025
bba5d34
remove audience parameter and do not set it at all for test tokens
dimpavloff Aug 26, 2025
607868b
test for grpc codes in TestTokenFileCallCreds_GetRequestMetadata
dimpavloff Aug 26, 2025
bc2d327
use cmp.Diff in TestTokenFileCallCreds_TokenCaching
dimpavloff Aug 26, 2025
330d9a8
fix createTestJWT docstring
dimpavloff Aug 29, 2025
774d83e
refactor readToken() and tests to use error values
dimpavloff Sep 1, 2025
b9dcfcb
remove errJWTFormat in favour of validation error
dimpavloff Sep 3, 2025
6ee5ba7
error wrapping
dimpavloff Sep 3, 2025
14c5ccd
subtests with underscores only
dimpavloff Sep 3, 2025
ca8227d
change credentials.CheckSecurityLevel error mgs; success path identation
dimpavloff Sep 3, 2025
ff50123
re-order assertions
dimpavloff Sep 3, 2025
c05da9f
remove string comparisons
dimpavloff Sep 3, 2025
3f9195e
add TODO to tests
dimpavloff Sep 5, 2025
2c9a06d
rename jWTFileReader to jwtFileReader
dimpavloff Sep 10, 2025
42c6804
move error wrapping
dimpavloff Sep 10, 2025
f1a1cd3
remove leftover package docstring
dimpavloff Sep 10, 2025
2b8ae01
use RawURLEncoding.DecodeString
dimpavloff Sep 11, 2025
1b5a609
%v instead of %w in credentials.CheckSecurityLevel error string
dimpavloff Sep 11, 2025
4c30c68
single check for preemptive refresh
dimpavloff Sep 11, 2025
0c15d73
clarify why lock is not used and document concurrent calls for jwtFil…
dimpavloff Sep 11, 2025
7d5f578
rename test suite function name
dimpavloff Sep 11, 2025
c8852fc
trailing brace in comment
dimpavloff Sep 11, 2025
0be0243
add comments to clarify we do not trigger refresh on updating the cac…
dimpavloff Sep 12, 2025
36042db
shouldTriggerRefresh failure message update
dimpavloff Sep 12, 2025
7e50e3e
re-use err instead of err1,2,3,4,5
dimpavloff Sep 12, 2025
8e3b91b
improve err==nil failure message in test
dimpavloff Sep 12, 2025
4ac6f4c
t.Fatal and t.Error message capitalisation where possible
dimpavloff Sep 12, 2025
e83fbee
combine t.Error into a single t.Fatal and indent
dimpavloff Sep 12, 2025
75fbc02
attempt to make the token referesh retry backoff test more readable
dimpavloff Sep 12, 2025
eae450a
rename function, omit zero value param, formatting
dimpavloff Sep 15, 2025
3b651c2
strip jwt_ prefix from filenames
dimpavloff Sep 15, 2025
4336d04
use strings.Cut to extract claims
dimpavloff Sep 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 12 additions & 16 deletions credentials/jwt/jwt_file_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,15 @@ type jwtClaims struct {
Exp int64 `json:"exp"`
}

// jWTFileReader handles reading and parsing JWT tokens from files.
type jWTFileReader struct {
// jwtFileReader handles reading and parsing JWT tokens from files.
// It is safe to call methods on this type concurrently as no state is stored.
type jwtFileReader struct {
tokenFilePath string
}

// readToken reads and parses a JWT token from the configured file.
Comment thread
arjan-bal marked this conversation as resolved.
// Returns the token string, expiration time, and any error encountered.
func (r *jWTFileReader) readToken() (string, time.Time, error) {
func (r *jwtFileReader) readToken() (string, time.Time, error) {
tokenBytes, err := os.ReadFile(r.tokenFilePath)
if err != nil {
return "", time.Time{}, fmt.Errorf("%v: %w", err, errTokenFileAccess)
Expand All @@ -58,44 +59,39 @@ func (r *jWTFileReader) readToken() (string, time.Time, error) {

exp, err := r.extractExpiration(token)
if err != nil {
return "", time.Time{}, fmt.Errorf("%q: %w", r.tokenFilePath, err)
return "", time.Time{}, fmt.Errorf("token file %q: %v: %w", r.tokenFilePath, err, errJWTValidation)
}

return token, exp, nil
}

// extractExpiration parses the JWT token to extract the expiration time.
func (r *jWTFileReader) extractExpiration(token string) (time.Time, error) {
func (r *jwtFileReader) extractExpiration(token string) (time.Time, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return time.Time{}, fmt.Errorf("expected 3 parts, got %d: %w", len(parts), errJWTValidation)
return time.Time{}, fmt.Errorf("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)
payloadBytes, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return time.Time{}, fmt.Errorf("decode error: %v: %w", err, errJWTValidation)
return time.Time{}, fmt.Errorf("decode error: %v", err)
}

var claims jwtClaims
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return time.Time{}, fmt.Errorf("unmarshal error: %v: %w", err, errJWTValidation)
return time.Time{}, fmt.Errorf("unmarshal error: %v", err)
}

if claims.Exp == 0 {
return time.Time{}, fmt.Errorf("no expiration claims: %w", errJWTValidation)
return time.Time{}, fmt.Errorf("no expiration claims")
}

expTime := time.Unix(claims.Exp, 0)

// Check if token is already expired.
if expTime.Before(time.Now()) {
return time.Time{}, fmt.Errorf("expired token: %w", errJWTValidation)
return time.Time{}, fmt.Errorf("expired token")
}

return expTime, nil
Expand Down
12 changes: 3 additions & 9 deletions credentials/jwt/jwt_file_reader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,8 @@ import (
"strings"
"testing"
"time"

"google.golang.org/grpc/internal/grpctest"
)

func TestJWTFileReader(t *testing.T) {
grpctest.RunSubTests(t, s{})
}

func (s) TestJWTFileReader_ReadToken_FileErrors(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -70,7 +64,7 @@ func (s) TestJWTFileReader_ReadToken_FileErrors(t *testing.T) {
tokenFile = writeTempFile(t, "token", tt.contents)
}

reader := jWTFileReader{tokenFilePath: tokenFile}
reader := jwtFileReader{tokenFilePath: tokenFile}
if _, _, err := reader.readToken(); err == nil {
t.Fatal("ReadToken() expected error, got nil")
} else if !errors.Is(err, tt.wantErr) {
Expand Down Expand Up @@ -118,7 +112,7 @@ func (s) TestJWTFileReader_ReadToken_InvalidJWT(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
tokenFile := writeTempFile(t, "token", tt.tokenContent)

reader := jWTFileReader{tokenFilePath: tokenFile}
reader := jwtFileReader{tokenFilePath: tokenFile}
if _, _, err := reader.readToken(); err == nil {
t.Fatal("ReadToken() expected error, got nil")
} else if !errors.Is(err, tt.wantErr) {
Comment thread
easwars marked this conversation as resolved.
Expand All @@ -134,7 +128,7 @@ func (s) TestJWTFileReader_ReadToken_ValidToken(t *testing.T) {
token := createTestJWT(t, tokenExp)
tokenFile := writeTempFile(t, "token", token)

reader := jWTFileReader{tokenFilePath: tokenFile}
reader := jwtFileReader{tokenFilePath: tokenFile}
readToken, expiry, err := reader.readToken()
if err != nil {
t.Fatalf("ReadToken() unexpected error: %v", err)
Expand Down
25 changes: 13 additions & 12 deletions credentials/jwt/jwt_token_file_call_creds.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
*
*/

// Package jwt implements gRPC credentials using JWT tokens from files.
package jwt

import (
Expand All @@ -38,7 +37,7 @@ const preemptiveRefreshThreshold = time.Minute
// tokens from a file.
// This implementation follows the A97 JWT Call Credentials specification.
type jwtTokenFileCallCreds struct {
fileReader *jWTFileReader
fileReader *jwtFileReader
backoffStrategy backoff.Strategy

// cached data protected by mu
Expand All @@ -59,7 +58,7 @@ func NewTokenFileCallCredentials(tokenFilePath string) (credentials.PerRPCCreden
}

creds := &jwtTokenFileCallCreds{
fileReader: &jWTFileReader{tokenFilePath: tokenFilePath},
fileReader: &jwtFileReader{tokenFilePath: tokenFilePath},
backoffStrategy: backoff.DefaultExponential,
}

Expand All @@ -77,21 +76,19 @@ func NewTokenFileCallCredentials(tokenFilePath string) (credentials.PerRPCCreden
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: %w", err)
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 {
if needsPreemptiveRefresh && !c.pendingRefresh {
// Start refresh if not pending (handling the prior RPC may have
// just spawned a goroutine).
if !c.pendingRefresh {
c.pendingRefresh = true
go c.refreshToken()
}
c.pendingRefresh = true
go c.refreshToken()
}
return map[string]string{
"authorization": c.cachedAuthHeader,
Expand Down Expand Up @@ -137,19 +134,23 @@ func (c *jwtTokenFileCallCreds) isTokenValidLocked() bool {

// refreshToken reads the token from file and updates the cached data.
func (c *jwtTokenFileCallCreds) refreshToken() {
// Deliberately not locking c.mu here
// 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 {
Expand All @@ -173,7 +174,7 @@ func (c *jwtTokenFileCallCreds) updateCacheLocked(token string, expiry time.Time
c.nextRetryTime = time.Time{}

c.cachedAuthHeader = "Bearer " + token
// Per RFC A97: consider token invalid if it expires within the next 30
// 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)
}
Loading
Loading