This change adds validation for ID tokens generated for GCP VMs. (#24492)

This commit is contained in:
Andrew Burke
2023-05-03 19:06:54 +00:00
committed by GitHub
parent 598e09f202
commit e17e2df710
6 changed files with 468 additions and 101 deletions
+69
View File
@@ -0,0 +1,69 @@
/*
Copyright 2023 Gravitational, Inc.
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 gcp
import (
"github.com/gravitational/trace"
"github.com/mitchellh/mapstructure"
)
// defaultIssuerHost is the issuer for GCP ID tokens.
const defaultIssuerHost = "accounts.google.com"
// ComputeEngine contains VM-specific token claims.
type computeEngine struct {
// The ID of the instance's project.
ProjectID string `json:"project_id"`
// The instance's zone.
Zone string `json:"zone"`
// The instance's ID.
InstanceID string `json:"instance_id"`
// The instance's name.
InstanceName string `json:"instance_name"`
}
// Google contains Google-specific token claims.
type google struct {
ComputeEngine computeEngine `json:"compute_engine"`
}
// IDTokenClaims is the set of claims in a GCP ID token. GCP documentation for
// claims can be found at
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity#payload
type IDTokenClaims struct {
// The email of the service account that this token was issued for.
Email string `json:"email"`
Google google `json:"google"`
}
// JoinAuditAttributes returns a series of attributes that can be inserted into
// audit events related to a specific join.
func (c *IDTokenClaims) JoinAuditAttributes() (map[string]interface{}, error) {
res := map[string]interface{}{}
d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
TagName: "json",
Result: &res,
})
if err != nil {
return nil, trace.Wrap(err)
}
if err := d.Decode(c); err != nil {
return nil, trace.Wrap(err)
}
return res, nil
}
+96
View File
@@ -0,0 +1,96 @@
/*
Copyright 2023 Gravitational, Inc.
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 gcp
import (
"context"
"fmt"
"time"
"github.com/coreos/go-oidc"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/gravitational/teleport/lib/jwt"
)
// IDTokenValidatorConfig is the config for IDTokenValidator.
type IDTokenValidatorConfig struct {
// Clock is used by the validator when checking expiry and issuer times of
// tokens. If omitted, a real clock will be used.
Clock clockwork.Clock
// issuerHost is the host of the Issuer for tokens issued by Google, to be
// overridden in tests. Defaults to "accounts.google.com".
issuerHost string
// insecure configures the validator to use HTTP rather than HTTPS, to be
// overridden in tests.
insecure bool
}
// IDTokenValidator validates ID tokens from GCP.
type IDTokenValidator struct {
IDTokenValidatorConfig
}
func NewIDTokenValidator(cfg IDTokenValidatorConfig) *IDTokenValidator {
if cfg.Clock == nil {
cfg.Clock = clockwork.NewRealClock()
}
if cfg.issuerHost == "" {
cfg.issuerHost = defaultIssuerHost
}
return &IDTokenValidator{
IDTokenValidatorConfig: cfg,
}
}
func (id *IDTokenValidator) issuerURL() string {
scheme := "https"
if id.insecure {
scheme = "http"
}
return fmt.Sprintf("%s://%s", scheme, id.issuerHost)
}
// Validate validates an ID token.
func (id *IDTokenValidator) Validate(ctx context.Context, token string) (*IDTokenClaims, error) {
p, err := oidc.NewProvider(ctx, id.issuerURL())
if err != nil {
return nil, trace.Wrap(err)
}
verifier := p.Verifier(&oidc.Config{
ClientID: "teleport.cluster.local",
Now: id.Clock.Now,
})
idToken, err := verifier.Verify(ctx, token)
if err != nil {
return nil, trace.Wrap(err)
}
// `go-oidc` does not implement not before check, so we need to manually
// perform this
if err := jwt.CheckNotBefore(id.Clock.Now(), time.Minute*2, idToken); err != nil {
return nil, trace.Wrap(err)
}
claims := IDTokenClaims{}
if err := idToken.Claims(&claims); err != nil {
return nil, trace.Wrap(err)
}
return &claims, nil
}
+249
View File
@@ -0,0 +1,249 @@
/*
Copyright 2023 Gravitational, Inc.
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 gcp
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/jonboulle/clockwork"
"github.com/stretchr/testify/require"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
)
type fakeIDP struct {
t *testing.T
signer jose.Signer
privateKey *rsa.PrivateKey
server *httptest.Server
}
func newFakeIDP(t *testing.T) *fakeIDP {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.RS256, Key: privateKey},
(&jose.SignerOptions{}).WithType("JWT"),
)
require.NoError(t, err)
f := &fakeIDP{
signer: signer,
privateKey: privateKey,
t: t,
}
providerMux := http.NewServeMux()
providerMux.HandleFunc(
"/.well-known/openid-configuration",
f.handleOpenIDConfig,
)
providerMux.HandleFunc(
"/.well-known/jwks",
f.handleJWKSEndpoint,
)
srv := httptest.NewServer(providerMux)
t.Cleanup(srv.Close)
f.server = srv
return f
}
func (f *fakeIDP) issuer() string {
return f.server.URL
}
func (f *fakeIDP) handleOpenIDConfig(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"claims_supported": []string{
"sub",
"aud",
"exp",
"iat",
"iss",
"azp",
"email",
"google",
},
"id_token_signing_alg_values_supported": []string{"RS256"},
"issuer": f.issuer(),
"jwks_uri": f.issuer() + "/.well-known/jwks",
"response_types_supported": []string{"id_token"},
"scopes_supported": []string{"openid"},
"subject_types_supported": []string{"public", "pairwise"},
}
responseBytes, err := json.Marshal(response)
require.NoError(f.t, err)
_, err = w.Write(responseBytes)
require.NoError(f.t, err)
}
func (f *fakeIDP) handleJWKSEndpoint(w http.ResponseWriter, r *http.Request) {
jwks := jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{
{
Key: &f.privateKey.PublicKey,
},
},
}
responseBytes, err := json.Marshal(jwks)
require.NoError(f.t, err)
_, err = w.Write(responseBytes)
require.NoError(f.t, err)
}
func (f *fakeIDP) issueToken(
t *testing.T,
issuer,
audience,
sub string,
claims computeEngine,
issuedAt time.Time,
expiry time.Time,
) string {
stdClaims := jwt.Claims{
Issuer: issuer,
Subject: sub,
Audience: jwt.Audience{audience},
IssuedAt: jwt.NewNumericDate(issuedAt),
NotBefore: jwt.NewNumericDate(issuedAt),
Expiry: jwt.NewNumericDate(expiry),
}
token, err := jwt.Signed(f.signer).
Claims(stdClaims).
Claims(IDTokenClaims{
Google: google{
ComputeEngine: claims,
},
}).
CompactSerialize()
require.NoError(t, err)
return token
}
func TestIDTokenValidator_Validate(t *testing.T) {
t.Parallel()
idp := newFakeIDP(t)
clock := clockwork.NewFakeClock()
sampleCE := computeEngine{
ProjectID: "12345678",
Zone: "z",
InstanceID: "87654321",
InstanceName: "test-instance",
}
tests := []struct {
name string
assertError require.ErrorAssertionFunc
want computeEngine
token string
}{
{
name: "success",
assertError: require.NoError,
token: idp.issueToken(
t,
idp.issuer(),
"teleport.cluster.local",
"abcd1234",
sampleCE,
clock.Now().Add(-5*time.Minute),
clock.Now().Add(5*time.Minute),
),
want: sampleCE,
},
{
name: "expired",
assertError: require.Error,
token: idp.issueToken(
t,
idp.issuer(),
"teleport.cluster.local",
"abcd1234",
sampleCE,
clock.Now().Add(-15*time.Minute),
clock.Now().Add(-5*time.Minute),
),
},
{
name: "future",
assertError: require.Error,
token: idp.issueToken(
t,
idp.issuer(),
"teleport.cluster.local",
"abcd1234",
sampleCE,
clock.Now().Add(10*time.Minute),
clock.Now().Add(20*time.Minute),
),
},
{
name: "invalid audience",
assertError: require.Error,
token: idp.issueToken(
t,
idp.issuer(),
"incorrect.audience",
"abcd1234",
sampleCE,
clock.Now().Add(-5*time.Minute),
clock.Now().Add(5*time.Minute),
),
},
{
name: "invalid issuer",
assertError: require.Error,
token: idp.issueToken(
t,
"http://the.wrong.issuer",
"teleport.cluster.local",
"abcd1234",
sampleCE,
clock.Now().Add(-5*time.Minute),
clock.Now().Add(5*time.Minute),
),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
v := NewIDTokenValidator(IDTokenValidatorConfig{
Clock: clock,
issuerHost: idp.server.Listener.Addr().String(),
insecure: true,
})
claims, err := v.Validate(ctx, tc.token)
tc.assertError(t, err)
if err == nil {
require.NotNil(t, claims)
require.Equal(t, tc.want, claims.Google.ComputeEngine)
}
})
}
}
+3 -49
View File
@@ -18,13 +18,14 @@ package githubactions
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/coreos/go-oidc"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/gravitational/teleport/lib/jwt"
)
type IDTokenValidatorConfig struct {
@@ -90,7 +91,7 @@ func (id *IDTokenValidator) Validate(ctx context.Context, GHESHost string, token
// `go-oidc` does not implement not before check, so we need to manually
// perform this
if err := checkNotBefore(id.Clock.Now(), time.Minute*2, idToken); err != nil {
if err := jwt.CheckNotBefore(id.Clock.Now(), time.Minute*2, idToken); err != nil {
return nil, trace.Wrap(err)
}
@@ -100,50 +101,3 @@ func (id *IDTokenValidator) Validate(ctx context.Context, GHESHost string, token
}
return &claims, nil
}
// checkNotBefore ensures the token was not issued in the future.
// https://www.rfc-editor.org/rfc/rfc7519#section-4.1.5
// 4.1.5. "nbf" (Not Before) Claim
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
func checkNotBefore(now time.Time, leeway time.Duration, token *oidc.IDToken) error {
claims := struct {
NotBefore *jsonTime `json:"nbf"`
}{}
if err := token.Claims(&claims); err != nil {
return trace.Wrap(err)
}
if claims.NotBefore != nil {
adjustedNow := now.Add(leeway)
nbf := time.Time(*claims.NotBefore)
if adjustedNow.Before(nbf) {
return trace.AccessDenied("token not before in future")
}
}
return nil
}
// jsonTime unmarshaling sourced from https://github.com/gravitational/go-oidc/blob/master/oidc.go#L295
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
type jsonTime time.Time
func (j *jsonTime) UnmarshalJSON(b []byte) error {
var n json.Number
if err := json.Unmarshal(b, &n); err != nil {
return err
}
var unix int64
if t, err := n.Int64(); err == nil {
unix = t
} else {
f, err := n.Float64()
if err != nil {
return err
}
unix = int64(f)
}
*j = jsonTime(time.Unix(unix, 0))
return nil
}
+2 -52
View File
@@ -18,7 +18,6 @@ package gitlab
import (
"context"
"encoding/json"
"fmt"
"time"
@@ -27,6 +26,7 @@ import (
"github.com/jonboulle/clockwork"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/jwt"
"github.com/gravitational/teleport/lib/services"
)
@@ -105,7 +105,7 @@ func (id *IDTokenValidator) Validate(
// `go-oidc` does not implement not before check, so we need to manually
// perform this
if err := checkNotBefore(
if err := jwt.CheckNotBefore(
id.Clock.Now(), time.Minute*2, idToken,
); err != nil {
return nil, trace.Wrap(err)
@@ -117,53 +117,3 @@ func (id *IDTokenValidator) Validate(
}
return &claims, nil
}
// checkNotBefore ensures the token was not issued in the future.
// https://www.rfc-editor.org/rfc/rfc7519#section-4.1.5
// 4.1.5. "nbf" (Not Before) Claim
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
func checkNotBefore(
now time.Time, leeway time.Duration, token *oidc.IDToken,
) error {
claims := struct {
NotBefore *jsonTime `json:"nbf"`
}{}
if err := token.Claims(&claims); err != nil {
return trace.Wrap(err)
}
if claims.NotBefore != nil {
adjustedNow := now.Add(leeway)
nbf := time.Time(*claims.NotBefore)
if adjustedNow.Before(nbf) {
return trace.AccessDenied("token not before in future")
}
}
return nil
}
// jsonTime unmarshaling sourced from
// https://github.com/gravitational/go-oidc/blob/master/oidc.go#L295
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
type jsonTime time.Time
func (j *jsonTime) UnmarshalJSON(b []byte) error {
var n json.Number
if err := json.Unmarshal(b, &n); err != nil {
return err
}
var unix int64
if t, err := n.Int64(); err == nil {
unix = t
} else {
f, err := n.Float64()
if err != nil {
return err
}
unix = int64(f)
}
*j = jsonTime(time.Unix(unix, 0))
return nil
}
+49
View File
@@ -24,10 +24,12 @@ import (
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/coreos/go-oidc"
"github.com/google/uuid"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
@@ -524,3 +526,50 @@ func GenerateKeyPair() ([]byte, []byte, error) {
return public, private, nil
}
// CheckNotBefore ensures the token was not issued in the future.
// https://www.rfc-editor.org/rfc/rfc7519#section-4.1.5
// 4.1.5. "nbf" (Not Before) Claim
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
func CheckNotBefore(now time.Time, leeway time.Duration, token *oidc.IDToken) error {
claims := struct {
NotBefore *JSONTime `json:"nbf"`
}{}
if err := token.Claims(&claims); err != nil {
return trace.Wrap(err)
}
if claims.NotBefore != nil {
adjustedNow := now.Add(leeway)
nbf := time.Time(*claims.NotBefore)
if adjustedNow.Before(nbf) {
return trace.AccessDenied("token not before in future")
}
}
return nil
}
// JSONTime unmarshaling sourced from https://github.com/gravitational/go-oidc/blob/master/oidc.go#L295
// TODO(strideynet): upstream support for `nbf` into the go-oidc lib.
type JSONTime time.Time
func (j *JSONTime) UnmarshalJSON(b []byte) error {
var n json.Number
if err := json.Unmarshal(b, &n); err != nil {
return err
}
var unix int64
if t, err := n.Int64(); err == nil {
unix = t
} else {
f, err := n.Float64()
if err != nil {
return err
}
unix = int64(f)
}
*j = JSONTime(time.Unix(unix, 0))
return nil
}