server/gcpcredential/validate.go (200 lines of code) (raw):
// Package gcpcredential contains functions to validate Google-issued ID tokens.
package gcpcredential
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"math/big"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
"google.golang.org/api/idtoken"
"google.golang.org/api/option"
)
const googleCAURL = "https://pki.goog/roots.pem"
// See https://pki.goog/faq/#faq-27 for more information about Google CAs.
func defaultHTTPClient() (*http.Client, error) {
resp, err := http.Get(googleCAURL)
if err != nil {
return nil, fmt.Errorf("Unable to retrieve Google CAs: %v", err)
}
bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("Unable to read response body: %v", err)
}
certs := x509.NewCertPool()
if !certs.AppendCertsFromPEM(bodyBytes) {
return nil, errors.New("failed to parse Google CA certificates")
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certs,
MinVersion: tls.VersionTLS13,
},
},
}, nil
}
// Validate validates each of the provided credentials, then returns the emails of the successfully verified tokens/emails.
// If an http.Client is provided, it will be used to initialize the idtoken validation client.
func Validate(ctx context.Context, client *http.Client, credentials []string, expectedAudience string) ([]string, error) {
if client == nil {
var err error
client, err = defaultHTTPClient()
if err != nil {
return nil, err
}
}
validatorOptions := []idtoken.ClientOption{
option.WithoutAuthentication(),
option.WithHTTPClient(client),
}
v, err := idtoken.NewValidator(ctx, validatorOptions...)
if err != nil {
return nil, fmt.Errorf("could not create ID token validator: %v", err.Error())
}
validator := func(token string) (map[string]any, error) {
payload, err := v.Validate(ctx, token, expectedAudience)
if err != nil {
return nil, err
}
return payload.Claims, nil
}
return validateAndParse(credentials, validator)
}
// JWK is a subset of the JSON Web Key (JWK) format.
type JWK struct {
Alg string `json:"alg"`
Crv string `json:"crv"`
Kid string `json:"kid"`
Kty string `json:"kty"`
Use string `json:"use"`
E string `json:"e"`
N string `json:"n"`
X string `json:"x"`
Y string `json:"y"`
}
// JWKS is a subset of the JSON Web Key Set (JWKSet) format.
type JWKS struct {
Keys []JWK `json:"keys"`
}
func rsaPubKey(key JWK) (*rsa.PublicKey, error) {
decodedN, err := base64.RawURLEncoding.DecodeString(key.N)
if err != nil {
return nil, err
}
decodedE, err := base64.RawURLEncoding.DecodeString(key.E)
if err != nil {
return nil, err
}
return &rsa.PublicKey{
N: new(big.Int).SetBytes(decodedN),
E: int(new(big.Int).SetBytes(decodedE).Int64()),
}, nil
}
func ecdsaPubKey(key JWK) (*ecdsa.PublicKey, error) {
decodedX, err := base64.RawURLEncoding.DecodeString(key.X)
if err != nil {
return nil, err
}
decodedY, err := base64.RawURLEncoding.DecodeString(key.Y)
if err != nil {
return nil, err
}
return &ecdsa.PublicKey{
Curve: elliptic.P256(),
X: new(big.Int).SetBytes(decodedX),
Y: new(big.Int).SetBytes(decodedY),
}, nil
}
// ValidateWithJWKS validates the provided credentials using the provided public keys.
// It is the caller's responsibility to retrieve and provide Google's JWKs (https://www.googleapis.com/oauth2/v3/certs).
func ValidateWithJWKS(jwks *JWKS, credentials []string, expectedAudience string) ([]string, error) {
// For JWT validation - finds the JWK that corresponds to the tokens Key ID and parses it into its respective key type.
keyFunc := func(token *jwt.Token) (any, error) {
kid, ok := token.Header["kid"]
if !ok {
return nil, fmt.Errorf("token missing Key ID")
}
for _, k := range jwks.Keys {
if kid == k.Kid {
alg, ok := token.Header["alg"]
if !ok {
return nil, errors.New("no signing algorithm specified in token")
}
switch alg {
case "RS256":
return rsaPubKey(k)
case "ES256":
return ecdsaPubKey(k)
default:
return nil, fmt.Errorf("unsupported signing algorithm %v, expext RS256 or ES256", alg)
}
}
}
return nil, errors.New("no matching key found")
}
// Validates a Google-issued ID token per guidance at https://developers.google.com/identity/sign-in/web/backend-auth#verify-the-integrity-of-the-id-token.
validator := func(token string) (map[string]any, error) {
// Check the signature.
claims := jwt.MapClaims{}
_, err := jwt.ParseWithClaims(token, claims, keyFunc)
if err != nil {
return nil, err
}
// Check the audience.
audience := claims["aud"]
if audience != expectedAudience {
return nil, fmt.Errorf("unexpected audience: %v, token %s", audience, token)
}
// Check the issuer.
issuer := claims["iss"]
if issuer != "accounts.google.com" && issuer != "https://accounts.google.com" {
return nil, fmt.Errorf("invalid issuer: %v, token %s", issuer, token)
}
// Check the expiration.
// Numbers need to be converted to float64 first to avoid panicking (https://stackoverflow.com/a/29690346).
exp, ok := claims["exp"].(float64)
if !ok {
return nil, errors.New("unable to convert exp claim to float64")
}
if time.Now().Unix() > int64(exp) {
return nil, errors.New("token is expired")
}
return claims, nil
}
return validateAndParse(credentials, validator)
}
type validationFunc func(token string) (map[string]any, error)
func validateAndParse(credentials []string, validator validationFunc) ([]string, error) {
var emails []string
for i, token := range credentials {
claims, err := validator(token)
if err != nil {
return nil, fmt.Errorf("Error validating token in position %v: %v", i, err)
}
tokenClaims, err := parseEmailClaims(claims)
if err != nil {
fmt.Printf("Error with ID token in position %v: %v", i, err)
continue
}
if tokenClaims.Email == "" {
fmt.Printf("ID token in position %v has no email claim\n", i)
continue
}
if !tokenClaims.EmailVerified {
fmt.Printf("email claim for ID token in position %v is not verified\n", i)
continue
}
emails = append(emails, tokenClaims.Email)
}
return emails, nil
}
// Takes an idtoken.Payload, which stores claims in a map[string]any. We want to
// interpret the claims as a googleClaims struct. Instead of manually inspecting the map we just
// encode/decode via JSON.
// This is valid because the original claims were decoded from JSON (as part of the JWT).
// claims.go
func parseEmailClaims(mapClaims map[string]any) (*emailClaims, error) {
data, err := json.Marshal(mapClaims)
if err != nil {
return nil, fmt.Errorf("failed to marshal JSON: %w", err)
}
claims := &emailClaims{}
if err = json.Unmarshal(data, claims); err != nil {
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
}
return claims, nil
}
// The subset of claims we care about in Google-issued OpenID tokens.
// Full claims documented at:
//
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity#payload
// https://developers.google.com/identity/protocols/oauth2/openid-connect
type emailClaims struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}