Skip to content

Commit ebaf486

Browse files
authored
credentials: implement file-based JWT Call Credentials (part 1 for A97) (#8431)
Part one for grpc/proposal#492 (A97). This is done in a new `credentials/jwt` package to provide file-based PerRPCCallCredentials. It can be used beyond XDS. The package handles token reloading, caching, and validation as per A97 . There will be a separate PR which uses it in `xds/bootstrap`. Whilst implementing the above, I considered `credentials/oauth` and `credentials/xds` packages instead of creating a new one. The former package has `NewJWTAccessFromKey` and `jwtAccess` which seem very relevant at first. However, I think the `jwtAccess` behaviour seems more tailored towards Google services. Also, the refresh, caching, and error behaviour for A97 is quite different than what's already there and therefore a separate implementation would have still made sense. WRT `credentials/xds`, it could have been extended to both handle transport and call credentials. However, this is a bit at odds with A97 which says that the implementation should be non-XDS specific and, from reading between the lines, usable beyond XDS. I think the current approach makes review easier but because of the similarities with the other two packages, it is a bit confusing to navigate. Please let me know whether the structure should change. Relates to istio/istio#53532 RELEASE NOTES: - credentials: Add `credentials/jwt` package providing file-based JWT PerRPCCredentials (A97).
1 parent ca78c90 commit ebaf486

File tree

5 files changed

+1075
-0
lines changed

5 files changed

+1075
-0
lines changed

credentials/jwt/doc.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
*
3+
* Copyright 2025 gRPC authors.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
// Package jwt implements JWT token file-based call credentials.
20+
//
21+
// This package provides support for A97 JWT Call Credentials, allowing gRPC
22+
// clients to authenticate using JWT tokens read from files. While originally
23+
// designed for xDS environments, these credentials are general-purpose.
24+
//
25+
// The credentials can be used directly in gRPC clients or configured via xDS.
26+
//
27+
// # Token Requirements
28+
//
29+
// JWT tokens must:
30+
// - Be valid, well-formed JWT tokens with header, payload, and signature
31+
// - Include an "exp" (expiration) claim
32+
// - Be readable from the specified file path
33+
//
34+
// # Considerations
35+
//
36+
// - Tokens are cached until expiration to avoid excessive file I/O
37+
// - Transport security is required (RequireTransportSecurity returns true)
38+
// - Errors in reading tokens or parsing JWTs will result in RPC UNAVAILALBE or
39+
// UNAUTHENTICATED errors. The errors are cached and retried with exponential
40+
// backoff.
41+
//
42+
// This implementation is originally intended for use in service mesh
43+
// environments like Istio where JWT tokens are provisioned and rotated by the
44+
// infrastructure.
45+
//
46+
// # Experimental
47+
//
48+
// Notice: All APIs in this package are experimental and may be removed in a
49+
// later release.
50+
package jwt

credentials/jwt/file_reader.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
*
3+
* Copyright 2025 gRPC authors.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package jwt
20+
21+
import (
22+
"encoding/base64"
23+
"encoding/json"
24+
"errors"
25+
"fmt"
26+
"os"
27+
"strings"
28+
"time"
29+
)
30+
31+
var (
32+
errTokenFileAccess = errors.New("token file access error")
33+
errJWTValidation = errors.New("invalid JWT")
34+
)
35+
36+
// jwtClaims represents the JWT claims structure for extracting expiration time.
37+
type jwtClaims struct {
38+
Exp int64 `json:"exp"`
39+
}
40+
41+
// jwtFileReader handles reading and parsing JWT tokens from files.
42+
// It is safe to call methods on this type concurrently as no state is stored.
43+
type jwtFileReader struct {
44+
tokenFilePath string
45+
}
46+
47+
// readToken reads and parses a JWT token from the configured file.
48+
// Returns the token string, expiration time, and any error encountered.
49+
func (r *jwtFileReader) readToken() (string, time.Time, error) {
50+
tokenBytes, err := os.ReadFile(r.tokenFilePath)
51+
if err != nil {
52+
return "", time.Time{}, fmt.Errorf("%v: %w", err, errTokenFileAccess)
53+
}
54+
55+
token := strings.TrimSpace(string(tokenBytes))
56+
if token == "" {
57+
return "", time.Time{}, fmt.Errorf("token file %q is empty: %w", r.tokenFilePath, errJWTValidation)
58+
}
59+
60+
exp, err := r.extractExpiration(token)
61+
if err != nil {
62+
return "", time.Time{}, fmt.Errorf("token file %q: %v: %w", r.tokenFilePath, err, errJWTValidation)
63+
}
64+
65+
return token, exp, nil
66+
}
67+
68+
const tokenDelim = "."
69+
70+
// extractClaimsRaw returns the JWT's claims part as raw string. Even though the
71+
// header and signature are not used, it still expects that the input string to
72+
// be well-formed (ie comprised of exactly three parts, separated by a dot
73+
// character).
74+
func extractClaimsRaw(s string) (string, bool) {
75+
_, s, ok := strings.Cut(s, tokenDelim)
76+
if !ok { // no period found
77+
return "", false
78+
}
79+
claims, s, ok := strings.Cut(s, tokenDelim)
80+
if !ok { // only one period found
81+
return "", false
82+
}
83+
_, _, ok = strings.Cut(s, tokenDelim)
84+
if ok { // three periods found
85+
return "", false
86+
}
87+
return claims, true
88+
}
89+
90+
// extractExpiration parses the JWT token to extract the expiration time.
91+
func (r *jwtFileReader) extractExpiration(token string) (time.Time, error) {
92+
claimsRaw, ok := extractClaimsRaw(token)
93+
if !ok {
94+
return time.Time{}, fmt.Errorf("expected 3 parts in token")
95+
}
96+
payloadBytes, err := base64.RawURLEncoding.DecodeString(claimsRaw)
97+
if err != nil {
98+
return time.Time{}, fmt.Errorf("decode error: %v", err)
99+
}
100+
101+
var claims jwtClaims
102+
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
103+
return time.Time{}, fmt.Errorf("unmarshal error: %v", err)
104+
}
105+
106+
if claims.Exp == 0 {
107+
return time.Time{}, fmt.Errorf("no expiration claims")
108+
}
109+
110+
expTime := time.Unix(claims.Exp, 0)
111+
112+
// Check if token is already expired.
113+
if expTime.Before(time.Now()) {
114+
return time.Time{}, fmt.Errorf("expired token")
115+
}
116+
117+
return expTime, nil
118+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/*
2+
*
3+
* Copyright 2025 gRPC authors.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package jwt
20+
21+
import (
22+
"encoding/base64"
23+
"encoding/json"
24+
"errors"
25+
"fmt"
26+
"strings"
27+
"testing"
28+
"time"
29+
)
30+
31+
func (s) TestJWTFileReader_ReadToken_FileErrors(t *testing.T) {
32+
tests := []struct {
33+
name string
34+
create bool
35+
contents string
36+
wantErr error
37+
}{
38+
{
39+
name: "nonexistent_file",
40+
create: false,
41+
contents: "",
42+
wantErr: errTokenFileAccess,
43+
},
44+
{
45+
name: "empty_file",
46+
create: true,
47+
contents: "",
48+
wantErr: errJWTValidation,
49+
},
50+
{
51+
name: "file_with_whitespace_only",
52+
create: true,
53+
contents: " \n\t ",
54+
wantErr: errJWTValidation,
55+
},
56+
}
57+
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
var tokenFile string
61+
if !tt.create {
62+
tokenFile = "/does-not-exist"
63+
} else {
64+
tokenFile = writeTempFile(t, "token", tt.contents)
65+
}
66+
67+
reader := jwtFileReader{tokenFilePath: tokenFile}
68+
if _, _, err := reader.readToken(); err == nil {
69+
t.Fatal("ReadToken() expected error, got nil")
70+
} else if !errors.Is(err, tt.wantErr) {
71+
t.Fatalf("ReadToken() error = %v, want error %v", err, tt.wantErr)
72+
}
73+
})
74+
}
75+
}
76+
77+
func (s) TestJWTFileReader_ReadToken_InvalidJWT(t *testing.T) {
78+
now := time.Now().Truncate(time.Second)
79+
tests := []struct {
80+
name string
81+
tokenContent string
82+
wantErr error
83+
}{
84+
{
85+
name: "valid_token_without_expiration",
86+
tokenContent: createTestJWT(t, time.Time{}),
87+
wantErr: errJWTValidation,
88+
},
89+
{
90+
name: "expired_token",
91+
tokenContent: createTestJWT(t, now.Add(-time.Hour)),
92+
wantErr: errJWTValidation,
93+
},
94+
{
95+
name: "malformed_JWT_not_enough_parts",
96+
tokenContent: "invalid.jwt",
97+
wantErr: errJWTValidation,
98+
},
99+
{
100+
name: "malformed_JWT_invalid_base64",
101+
tokenContent: "header.invalid_base64!@#.signature",
102+
wantErr: errJWTValidation,
103+
},
104+
{
105+
name: "malformed_JWT_invalid_JSON",
106+
tokenContent: createInvalidJWT(t),
107+
wantErr: errJWTValidation,
108+
},
109+
}
110+
111+
for _, tt := range tests {
112+
t.Run(tt.name, func(t *testing.T) {
113+
tokenFile := writeTempFile(t, "token", tt.tokenContent)
114+
115+
reader := jwtFileReader{tokenFilePath: tokenFile}
116+
if _, _, err := reader.readToken(); err == nil {
117+
t.Fatal("ReadToken() expected error, got nil")
118+
} else if !errors.Is(err, tt.wantErr) {
119+
t.Fatalf("ReadToken() error = %v, want error %v", err, tt.wantErr)
120+
}
121+
})
122+
}
123+
}
124+
125+
func (s) TestJWTFileReader_ReadToken_ValidToken(t *testing.T) {
126+
now := time.Now().Truncate(time.Second)
127+
tokenExp := now.Add(time.Hour)
128+
token := createTestJWT(t, tokenExp)
129+
tokenFile := writeTempFile(t, "token", token)
130+
131+
reader := jwtFileReader{tokenFilePath: tokenFile}
132+
readToken, expiry, err := reader.readToken()
133+
if err != nil {
134+
t.Fatalf("ReadToken() unexpected error: %v", err)
135+
}
136+
137+
if readToken != token {
138+
t.Errorf("ReadToken() token = %q, want %q", readToken, token)
139+
}
140+
141+
if !expiry.Equal(tokenExp) {
142+
t.Errorf("ReadToken() expiry = %v, want %v", expiry, tokenExp)
143+
}
144+
}
145+
146+
// createInvalidJWT creates a JWT with invalid JSON in the payload.
147+
func createInvalidJWT(t *testing.T) string {
148+
t.Helper()
149+
150+
header := map[string]any{
151+
"typ": "JWT",
152+
"alg": "HS256",
153+
}
154+
155+
headerBytes, err := json.Marshal(header)
156+
if err != nil {
157+
t.Fatalf("Failed to marshal header: %v", err)
158+
}
159+
160+
headerB64 := base64.URLEncoding.EncodeToString(headerBytes)
161+
headerB64 = strings.TrimRight(headerB64, "=")
162+
163+
// Create invalid JSON payload
164+
invalidJSON := "invalid json content"
165+
payloadB64 := base64.URLEncoding.EncodeToString([]byte(invalidJSON))
166+
payloadB64 = strings.TrimRight(payloadB64, "=")
167+
168+
signature := base64.URLEncoding.EncodeToString([]byte("fake_signature"))
169+
signature = strings.TrimRight(signature, "=")
170+
171+
return fmt.Sprintf("%s.%s.%s", headerB64, payloadB64, signature)
172+
}

0 commit comments

Comments
 (0)