blob: 62e357f482c81a9893796610fe2b34d37a3d117c [file]
/*
*
* 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"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/internal/transport"
"google.golang.org/grpc/status"
)
// The HTTP transport timeout values match newDefaultHTTPClient in the
// GCE metadata client package (cloud.google.com/go/compute/metadata):
//
// Proxy is nil to bypass environment proxies per gRFC A83, a 2s dial
// timeout fails fast if the local metadata server is unreachable, TCP
// keep-alive is set to 30s, and idle pooled connections close after 60s.
var defaultMetadataHTTPClient = &http.Client{
Transport: &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{
Timeout: 2 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
IdleConnTimeout: 60 * time.Second,
},
}
// idTokenFetcher fetches ID tokens from the GCE Metadata Server.
type idTokenFetcher struct {
client *http.Client // unproxied HTTP client for metadata server communication
metadataHost string // hostname of the GCE metadata server
}
// newIDTokenFetcher creates a new idTokenFetcher configured to send direct,
// unproxied HTTP requests to the GCE metadata server.
func newIDTokenFetcher() *idTokenFetcher {
host := os.Getenv("GCE_METADATA_HOST")
if host == "" {
host = "metadata.google.internal"
}
return &idTokenFetcher{
metadataHost: host,
client: defaultMetadataHTTPClient,
}
}
// fetchIDToken fetches an ID token for the given audience from the GCE
// Metadata Server.
//
// It takes a context controlling HTTP request cancellation and an audience
// string for the requested token. It returns the raw JWT token string,
// expiration timestamp parsed from the token's "exp" claim, and an error
// mapped to a gRPC status if the HTTP request or JWT parsing fails:
// - 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 and JWT parsing failures map to
// UNAUTHENTICATED.
// - Non-HTTP request failures are mapped to UNAVAILABLE.
func (f *idTokenFetcher) fetchIDToken(ctx context.Context, audience string) (string, time.Time, error) {
reqURL := fmt.Sprintf("http://%s/computeMetadata/v1/instance/service-accounts/default/identity?audience=%s&format=full", f.metadataHost, url.QueryEscape(audience))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return "", time.Time{}, status.Errorf(codes.Unavailable, "credentials: failed to fetch ID token: %v", err)
}
req.Header.Set("Metadata-Flavor", "Google")
resp, err := f.client.Do(req)
if err != nil {
return "", time.Time{}, status.Errorf(codes.Unavailable, "credentials: failed to fetch ID token: %v", err)
}
defer resp.Body.Close()
// Limit the response body read to 1 MB to prevent unbounded memory
// allocation if the server returns an unexpectedly large response, matching
// the limit used by the golang.org/x/oauth2 package when reading token
// responses.
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", time.Time{}, status.Errorf(codes.Unavailable, "credentials: failed to fetch ID token: %v", err)
}
// Per gRFC A83, if the returned HTTP status maps to UNAVAILABLE in the
// standard HTTP to gRPC status code mapping, return UNAVAILABLE; otherwise,
// return UNAUTHENTICATED.
if resp.StatusCode != http.StatusOK {
switch transport.HTTPStatusConvTab[resp.StatusCode] {
case codes.Unavailable:
return "", time.Time{}, status.Errorf(codes.Unavailable, "credentials: failed to fetch ID token: HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
default:
return "", time.Time{}, status.Errorf(codes.Unauthenticated, "credentials: failed to fetch ID token: HTTP status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
}
// Handle the HTTP 200 OK case.
rawJWT := strings.TrimSpace(string(body))
expiry, err := parseJWTExpiry(rawJWT)
if err != nil {
return "", time.Time{}, status.Errorf(codes.Unauthenticated, "credentials: failed to fetch ID token: %v", err)
}
return rawJWT, expiry, nil
}
type jwtPayload struct {
// Per RFC 7519 Section 2, JWT NumericDate values (such as "exp") are JSON
// numeric values that may contain non-integer fractional seconds. Using
// float64 ensures json.Unmarshal succeeds even if fractional seconds are
// present.
Exp float64 `json:"exp"`
}
// parseJWTExpiry parses a JWT string to extract its expiration timestamp
// ("exp" claim).
//
// A compact JWT consists of three dot-separated, Base64URL-encoded parts:
// 1. Header: Contains metadata like algorithm and token type. Ignored here.
// 2. Payload: Contains token claims in JSON format. Decoded to extract the
// expiration timestamp ("exp").
// 3. Signature: Cryptographic signature. Ignored here.
//
// Per gRFC A83, if the JWT format is invalid or the "exp" claim cannot be
// extracted, an error is returned.
func parseJWTExpiry(jwtStr string) (time.Time, error) {
// A valid JWT must have 3 parts: header, payload, and signature.
parts := strings.Split(jwtStr, ".")
if len(parts) != 3 {
return time.Time{}, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
}
// Per the JWT specification (RFC 7519 / RFC 7515), compact JWT segments use
// Base64URL encoding with all trailing '=' padding characters omitted.
// base64.RawURLEncoding is unpadded Base64URL encoding and decodes these
// strings directly without requiring manual padding.
data, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return time.Time{}, fmt.Errorf("failed to decode JWT payload: %v", err)
}
var payload jwtPayload
if err := json.Unmarshal(data, &payload); err != nil {
return time.Time{}, fmt.Errorf("failed to unmarshal JWT payload: %v", err)
}
if payload.Exp <= 0 {
return time.Time{}, fmt.Errorf("missing or invalid 'exp' claim in JWT payload")
}
return time.Unix(int64(payload.Exp), 0), nil
}