Add support for GCP identity tokens.
This commit is contained in:
parent
6af1e95c5b
commit
f794dbeb93
5 changed files with 345 additions and 10 deletions
|
@ -70,13 +70,17 @@ func (c *Collection) LoadByToken(token *jose.JSONWebToken, claims *jose.Claims)
|
||||||
if err := token.UnsafeClaimsWithoutVerification(&payload); err != nil {
|
if err := token.UnsafeClaimsWithoutVerification(&payload); err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
// audience is required
|
// Audience is required
|
||||||
if len(payload.Audience) == 0 {
|
if len(payload.Audience) == 0 {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
// Try with azp (OIDC)
|
||||||
if len(payload.AuthorizedParty) > 0 {
|
if len(payload.AuthorizedParty) > 0 {
|
||||||
return c.Load(payload.AuthorizedParty)
|
if p, ok := c.Load(payload.AuthorizedParty); ok {
|
||||||
|
return p, ok
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Fallback to aud (GCP)
|
||||||
return c.Load(payload.Audience[0])
|
return c.Load(payload.Audience[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
233
authority/provisioner/gcp.go
Normal file
233
authority/provisioner/gcp.go
Normal file
|
@ -0,0 +1,233 @@
|
||||||
|
package provisioner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/smallstep/cli/jose"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gcpPayload extends jwt.Claims with custom GCP attributes.
|
||||||
|
type gcpPayload struct {
|
||||||
|
jose.Claims
|
||||||
|
AuthorizedParty string `json:"azp"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
Google gcpGooglePayload `json:"google"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gcpGooglePayload struct {
|
||||||
|
ComputeEngine gcpComputeEnginePayload `json:"compute_engine"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gcpComputeEnginePayload struct {
|
||||||
|
InstanceID string `json:"instance_id"`
|
||||||
|
InstanceName string `json:"instance_name"`
|
||||||
|
InstanceCreationTimestamp *jose.NumericDate `json:"instance_creation_timestamp"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
ProjectNumber int64 `json:"project_number"`
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
LicenseID []string `json:"license_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GCP is the provisioner that supports identity tokens created by the Google
|
||||||
|
// Cloud Platform metadata API.
|
||||||
|
type GCP struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ServiceAccounts []string `json:"serviceAccounts"`
|
||||||
|
Claims *Claims `json:"claims,omitempty"`
|
||||||
|
claimer *Claimer
|
||||||
|
certStore *keyStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the provisioner unique identifier. The name should uniquely
|
||||||
|
// identify any GCP provisioner.
|
||||||
|
func (p *GCP) GetID() string {
|
||||||
|
return "gcp:" + p.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenID returns the identifier of the token. For GCP this is the sha256 of
|
||||||
|
// "instance_id.iat.exp".
|
||||||
|
func (p *GCP) GetTokenID(token string) (string, error) {
|
||||||
|
jwt, err := jose.ParseSigned(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "error parsing token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get claims w/out verification.
|
||||||
|
var claims gcpPayload
|
||||||
|
if err = jwt.UnsafeClaimsWithoutVerification(&claims); err != nil {
|
||||||
|
return "", errors.Wrap(err, "error verifying claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
unique := fmt.Sprintf("%s.%d.%d", claims.Google.ComputeEngine.InstanceID, claims.IssuedAt, claims.Expiry)
|
||||||
|
sum := sha256.Sum256([]byte(unique))
|
||||||
|
return strings.ToLower(hex.EncodeToString(sum[:])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetName returns the name of the provisioner.
|
||||||
|
func (p *GCP) GetName() string {
|
||||||
|
return p.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetType returns the type of provisioner.
|
||||||
|
func (p *GCP) GetType() Type {
|
||||||
|
return TypeGCP
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEncryptedKey is not available in a GCP provisioner.
|
||||||
|
func (p *GCP) GetEncryptedKey() (kid string, key string, ok bool) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init validates and initializes the GCP provider.
|
||||||
|
func (p *GCP) Init(config Config) error {
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case p.Type == "":
|
||||||
|
return errors.New("provisioner type cannot be empty")
|
||||||
|
case p.Name == "":
|
||||||
|
return errors.New("provisioner name cannot be empty")
|
||||||
|
}
|
||||||
|
// Update claims with global ones
|
||||||
|
if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Initialize certificate store
|
||||||
|
p.certStore, err = newCertificateStore("https://www.googleapis.com/oauth2/v1/certs")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizeSign validates the given token and returns the sign options that
|
||||||
|
// will be used on certificate creation.
|
||||||
|
func (p *GCP) AuthorizeSign(token string) ([]SignOption, error) {
|
||||||
|
claims, err := p.authorizeToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ce := claims.Google.ComputeEngine
|
||||||
|
dnsNames := []string{
|
||||||
|
fmt.Sprintf("%s.c.%s.internal", ce.InstanceName, ce.ProjectID),
|
||||||
|
fmt.Sprintf("%s.%s.c.%s.internal", ce.InstanceName, ce.Zone, ce.ProjectID),
|
||||||
|
}
|
||||||
|
|
||||||
|
return []SignOption{
|
||||||
|
commonNameValidator(ce.InstanceName),
|
||||||
|
dnsNamesValidator(dnsNames),
|
||||||
|
profileDefaultDuration(p.claimer.DefaultTLSCertDuration()),
|
||||||
|
newProvisionerExtensionOption(TypeGCP, p.Name, claims.AuthorizedParty),
|
||||||
|
newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizeRenewal returns an error if the renewal is disabled.
|
||||||
|
func (p *GCP) AuthorizeRenewal(cert *x509.Certificate) error {
|
||||||
|
if p.claimer.IsDisableRenewal() {
|
||||||
|
return errors.Errorf("renew is disabled for provisioner %s", p.GetID())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizeRevoke returns an error if the provisioner does not have rights to
|
||||||
|
// revoke a certificate.
|
||||||
|
func (p *GCP) AuthorizeRevoke(token string) error {
|
||||||
|
_, err := p.authorizeToken(token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIdentityURL returns the url that generates the GCP token.
|
||||||
|
func (p *GCP) GetIdentityURL() string {
|
||||||
|
audience := url.QueryEscape(p.GetID())
|
||||||
|
return fmt.Sprintf("http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=%s&format=full&licenses=FALSE", audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIdentityToken does an HTTP request to the identity url.
|
||||||
|
func (p *GCP) GetIdentityToken() (string, error) {
|
||||||
|
req, err := http.NewRequest("GET", p.GetIdentityURL(), http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "error creating identity request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Metadata-Flavor", "Google")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "error doing identity request, are you in a GCP VM?")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "error reading identity request response")
|
||||||
|
}
|
||||||
|
return string(bytes.TrimSpace(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizeToken performs common jwt authorization actions and returns the
|
||||||
|
// claims for case specific downstream parsing.
|
||||||
|
// e.g. a Sign request will auth/validate different fields than a Revoke request.
|
||||||
|
func (p *GCP) authorizeToken(token string) (*gcpPayload, error) {
|
||||||
|
jwt, err := jose.ParseSigned(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "error parsing token")
|
||||||
|
}
|
||||||
|
if len(jwt.Headers) == 0 {
|
||||||
|
return nil, errors.New("error parsing token: header is missing")
|
||||||
|
}
|
||||||
|
kid := jwt.Headers[0].KeyID
|
||||||
|
cert := p.certStore.GetCertificate(kid)
|
||||||
|
if cert == nil {
|
||||||
|
return nil, errors.Errorf("failed to validate payload: cannot find certificate for kid %s", kid)
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims gcpPayload
|
||||||
|
if err = jwt.Claims(cert.PublicKey, &claims); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "error parsing claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
// According to "rfc7519 JSON Web Token" acceptable skew should be no
|
||||||
|
// more than a few minutes.
|
||||||
|
if err = claims.ValidateWithLeeway(jose.Expected{
|
||||||
|
Issuer: "https://accounts.google.com",
|
||||||
|
Time: time.Now().UTC(),
|
||||||
|
Audience: []string{p.GetID()},
|
||||||
|
}, time.Minute); err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate authorized party
|
||||||
|
if len(p.ServiceAccounts) > 0 {
|
||||||
|
var found bool
|
||||||
|
for _, sa := range p.ServiceAccounts {
|
||||||
|
if sa == claims.AuthorizedParty {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, errors.New("invalid token: invalid authorized party claim (azp)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case claims.Google.ComputeEngine.InstanceID == "":
|
||||||
|
return nil, errors.New("token google.compute_engine.instance_id cannot be empty")
|
||||||
|
case claims.Google.ComputeEngine.ProjectID == "":
|
||||||
|
return nil, errors.New("token google.compute_engine.project_id cannot be empty")
|
||||||
|
case claims.Google.ComputeEngine.Zone == "":
|
||||||
|
return nil, errors.New("token google.compute_engine.zone cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &claims, nil
|
||||||
|
}
|
|
@ -33,7 +33,6 @@ func (p *JWK) GetID() string {
|
||||||
return p.Name + ":" + p.Key.KeyID
|
return p.Name + ":" + p.Key.KeyID
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// GetTokenID returns the identifier of the token.
|
// GetTokenID returns the identifier of the token.
|
||||||
func (p *JWK) GetTokenID(ott string) (string, error) {
|
func (p *JWK) GetTokenID(ott string) (string, error) {
|
||||||
// Validate payload
|
// Validate payload
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
package provisioner
|
package provisioner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
@ -20,10 +22,29 @@ const (
|
||||||
|
|
||||||
var maxAgeRegex = regexp.MustCompile("max-age=([0-9]*)")
|
var maxAgeRegex = regexp.MustCompile("max-age=([0-9]*)")
|
||||||
|
|
||||||
|
type oauth2Certificate struct {
|
||||||
|
ID string
|
||||||
|
Certificate *x509.Certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauth2CertificateSet struct {
|
||||||
|
Certificates []oauth2Certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s oauth2CertificateSet) Get(id string) *x509.Certificate {
|
||||||
|
for _, c := range s.Certificates {
|
||||||
|
if c.ID == id {
|
||||||
|
return c.Certificate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type keyStore struct {
|
type keyStore struct {
|
||||||
sync.RWMutex
|
sync.RWMutex
|
||||||
uri string
|
uri string
|
||||||
keySet jose.JSONWebKeySet
|
keySet jose.JSONWebKeySet
|
||||||
|
certSet oauth2CertificateSet
|
||||||
timer *time.Timer
|
timer *time.Timer
|
||||||
expiry time.Time
|
expiry time.Time
|
||||||
jitter time.Duration
|
jitter time.Duration
|
||||||
|
@ -45,6 +66,22 @@ func newKeyStore(uri string) (*keyStore, error) {
|
||||||
return ks, nil
|
return ks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newCertificateStore(uri string) (*keyStore, error) {
|
||||||
|
certs, age, err := getOauth2Certificates(uri)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ks := &keyStore{
|
||||||
|
uri: uri,
|
||||||
|
certSet: certs,
|
||||||
|
expiry: getExpirationTime(age),
|
||||||
|
jitter: getCacheJitter(age),
|
||||||
|
}
|
||||||
|
next := ks.nextReloadDuration(age)
|
||||||
|
ks.timer = time.AfterFunc(next, ks.reloadCertificates)
|
||||||
|
return ks, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (ks *keyStore) Close() {
|
func (ks *keyStore) Close() {
|
||||||
ks.timer.Stop()
|
ks.timer.Stop()
|
||||||
}
|
}
|
||||||
|
@ -62,6 +99,19 @@ func (ks *keyStore) Get(kid string) (keys []jose.JSONWebKey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ks *keyStore) GetCertificate(kid string) (cert *x509.Certificate) {
|
||||||
|
ks.RLock()
|
||||||
|
// Force reload if expiration has passed
|
||||||
|
if time.Now().After(ks.expiry) {
|
||||||
|
ks.RUnlock()
|
||||||
|
ks.reloadCertificates()
|
||||||
|
ks.RLock()
|
||||||
|
}
|
||||||
|
cert = ks.certSet.Get(kid)
|
||||||
|
ks.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (ks *keyStore) reload() {
|
func (ks *keyStore) reload() {
|
||||||
var next time.Duration
|
var next time.Duration
|
||||||
keys, age, err := getKeysFromJWKsURI(ks.uri)
|
keys, age, err := getKeysFromJWKsURI(ks.uri)
|
||||||
|
@ -81,6 +131,25 @@ func (ks *keyStore) reload() {
|
||||||
ks.Unlock()
|
ks.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ks *keyStore) reloadCertificates() {
|
||||||
|
var next time.Duration
|
||||||
|
certs, age, err := getOauth2Certificates(ks.uri)
|
||||||
|
if err != nil {
|
||||||
|
next = ks.nextReloadDuration(ks.jitter / 2)
|
||||||
|
} else {
|
||||||
|
ks.Lock()
|
||||||
|
ks.certSet = certs
|
||||||
|
ks.expiry = getExpirationTime(age)
|
||||||
|
ks.jitter = getCacheJitter(age)
|
||||||
|
next = ks.nextReloadDuration(age)
|
||||||
|
ks.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
ks.Lock()
|
||||||
|
ks.timer.Reset(next)
|
||||||
|
ks.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (ks *keyStore) nextReloadDuration(age time.Duration) time.Duration {
|
func (ks *keyStore) nextReloadDuration(age time.Duration) time.Duration {
|
||||||
n := rand.Int63n(int64(ks.jitter))
|
n := rand.Int63n(int64(ks.jitter))
|
||||||
age -= time.Duration(n)
|
age -= time.Duration(n)
|
||||||
|
@ -103,6 +172,34 @@ func getKeysFromJWKsURI(uri string) (jose.JSONWebKeySet, time.Duration, error) {
|
||||||
return keys, getCacheAge(resp.Header.Get("cache-control")), nil
|
return keys, getCacheAge(resp.Header.Get("cache-control")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getOauth2Certificates(uri string) (oauth2CertificateSet, time.Duration, error) {
|
||||||
|
var certs oauth2CertificateSet
|
||||||
|
resp, err := http.Get(uri)
|
||||||
|
if err != nil {
|
||||||
|
return certs, 0, errors.Wrapf(err, "failed to connect to %s", uri)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
m := make(map[string]string)
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
|
||||||
|
return certs, 0, errors.Wrapf(err, "error reading %s", uri)
|
||||||
|
}
|
||||||
|
for k, v := range m {
|
||||||
|
block, _ := pem.Decode([]byte(v))
|
||||||
|
if block == nil || block.Type != "CERTIFICATE" {
|
||||||
|
return certs, 0, errors.Wrapf(err, "error parsing certificate %s from %s", k, uri)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return certs, 0, errors.Wrapf(err, "error parsing certificate %s from %s", k, uri)
|
||||||
|
}
|
||||||
|
certs.Certificates = append(certs.Certificates, oauth2Certificate{
|
||||||
|
ID: k,
|
||||||
|
Certificate: cert,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return certs, getCacheAge(resp.Header.Get("cache-control")), nil
|
||||||
|
}
|
||||||
|
|
||||||
func getCacheAge(cacheControl string) time.Duration {
|
func getCacheAge(cacheControl string) time.Duration {
|
||||||
age := defaultCacheAge
|
age := defaultCacheAge
|
||||||
if len(cacheControl) > 0 {
|
if len(cacheControl) > 0 {
|
||||||
|
|
|
@ -37,12 +37,12 @@ type Type int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
noopType Type = 0
|
noopType Type = 0
|
||||||
|
|
||||||
// TypeJWK is used to indicate the JWK provisioners.
|
// TypeJWK is used to indicate the JWK provisioners.
|
||||||
TypeJWK Type = 1
|
TypeJWK Type = 1
|
||||||
|
|
||||||
// TypeOIDC is used to indicate the OIDC provisioners.
|
// TypeOIDC is used to indicate the OIDC provisioners.
|
||||||
TypeOIDC Type = 2
|
TypeOIDC Type = 2
|
||||||
|
// TypeGCP is used to indicate the GCP provisioners.
|
||||||
|
TypeGCP Type = 3
|
||||||
|
|
||||||
// RevokeAudienceKey is the key for the 'revoke' audiences in the audiences map.
|
// RevokeAudienceKey is the key for the 'revoke' audiences in the audiences map.
|
||||||
RevokeAudienceKey = "revoke"
|
RevokeAudienceKey = "revoke"
|
||||||
|
@ -86,6 +86,8 @@ func (l *List) UnmarshalJSON(data []byte) error {
|
||||||
p = &JWK{}
|
p = &JWK{}
|
||||||
case "oidc":
|
case "oidc":
|
||||||
p = &OIDC{}
|
p = &OIDC{}
|
||||||
|
case "gcp":
|
||||||
|
p = &GCP{}
|
||||||
default:
|
default:
|
||||||
return errors.Errorf("provisioner type %s not supported", typ.Type)
|
return errors.Errorf("provisioner type %s not supported", typ.Type)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue