diff --git a/lib/gcp/gcp.go b/lib/gcp/gcp.go new file mode 100644 index 0000000000000..199bd8327fa32 --- /dev/null +++ b/lib/gcp/gcp.go @@ -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 +} diff --git a/lib/gcp/token_validator.go b/lib/gcp/token_validator.go new file mode 100644 index 0000000000000..0f72a320aa8d4 --- /dev/null +++ b/lib/gcp/token_validator.go @@ -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 +} diff --git a/lib/gcp/token_validator_test.go b/lib/gcp/token_validator_test.go new file mode 100644 index 0000000000000..9db9acf298de4 --- /dev/null +++ b/lib/gcp/token_validator_test.go @@ -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) + } + }) + } +} diff --git a/lib/githubactions/token_validator.go b/lib/githubactions/token_validator.go index 6f882d92ee45a..8a879bf70178b 100644 --- a/lib/githubactions/token_validator.go +++ b/lib/githubactions/token_validator.go @@ -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 -} diff --git a/lib/gitlab/token_validator.go b/lib/gitlab/token_validator.go index 2198c4c926054..963f5af6c0cbc 100644 --- a/lib/gitlab/token_validator.go +++ b/lib/gitlab/token_validator.go @@ -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 -} diff --git a/lib/jwt/jwt.go b/lib/jwt/jwt.go index 483448864846e..e8298a4437e1d 100644 --- a/lib/jwt/jwt.go +++ b/lib/jwt/jwt.go @@ -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 +}