diff --git a/constants.go b/constants.go index f6c0ba3736fc2..59b4e1ef2cda7 100644 --- a/constants.go +++ b/constants.go @@ -537,6 +537,9 @@ const ( // membership information TraitTeams = "github_teams" + // TraitJWT is the name of the trait containing JWT header for app access. + TraitJWT = "jwt" + // TraitInternalLoginsVariable is the variable used to store allowed // logins for local accounts. TraitInternalLoginsVariable = "{{internal.logins}}" @@ -564,6 +567,10 @@ const ( // TraitInternalAWSRoleARNs is the variable used to store allowed AWS // role ARNs for local accounts. TraitInternalAWSRoleARNs = "{{internal.aws_role_arns}}" + + // TraitInternalJWTVariable is the variable used to store JWT token for + // app sessions. + TraitInternalJWTVariable = "{{internal.jwt}}" ) // SCP is Secure Copy. diff --git a/docs/pages/application-access/guides/connecting-apps.mdx b/docs/pages/application-access/guides/connecting-apps.mdx index 284911468086c..0cc06c9d54403 100644 --- a/docs/pages/application-access/guides/connecting-apps.mdx +++ b/docs/pages/application-access/guides/connecting-apps.mdx @@ -270,9 +270,11 @@ requests forwarded to a web application. headers: # Inject a static header. - "X-Custom-Header: example" - # Inject hedaers with internal/external user traits. + # Inject headers with internal/external user traits. - "X-Internal-Trait: {{internal.logins}}" - "X-External-Trait: {{external.env}}" + # Inject header with Teleport-signed JWT token. + - "Authorization: Bearer {{internal.jwt}}" # Override Host header. - "Host: dashboard.example.com" ``` @@ -286,6 +288,10 @@ In the example above, `X-Internal-Trait` header will be populated with the value of internal user trait `logins` and `X-External-Trait` header will get the value of the user's external `env` trait coming from the identity provider. +Additionally, the `{{internal.jwt}}` template variable will be replaced with +a JWT token signed by Teleport that contains user identity information. See +[Integrating with JWTs](./jwt.mdx) for more details. + ## View applications in Teleport Teleport provides a UI for quickly launching connected applications. diff --git a/docs/pages/application-access/guides/jwt.mdx b/docs/pages/application-access/guides/jwt.mdx index 3bffbcbaa0be7..57e6b3e830a60 100644 --- a/docs/pages/application-access/guides/jwt.mdx +++ b/docs/pages/application-access/guides/jwt.mdx @@ -69,6 +69,24 @@ The JWT will be sent with the header: `Teleport-Jwt-Assertion`. eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cDovLzEyNy4wLjAuMTozNDY3OSJdLCJleHAiOjE2MDM5NDM4MDAsImlzcyI6ImF3cyIsIm5iZiI6MTYwMzgzNTc5NSwicm9sZXMiOlsiYWRtaW4iXSwic3ViIjoiYmVuYXJlbnQiLCJ1c2VybmFtZSI6ImJlbmFyZW50In0.PZGUyFfhEWl22EDniWRLmKAjb3fL0D4cTmkxEfb-Q30hVMzVhka5WB8AUsPsLPVhTzsQ6Nkk1DnXHdz6oxrqDDfumuRrDnpJpjiXj_l0D3bExrchN61enzBHxSD13VkRIqP1V6l4i8yt8kXDIBWc-QejLTodA_GtczkDfnnpuAfaxIbD7jEwF27KI4kZu7uES9LMu2iCLdV9ZqarA-6HeDhXPA37OJ3P6eVQzYpgaOBYro5brEiVpuJLr1yA0gncmR4FqmhCpCj-KmHi2vmjmJAuuHId6HZoEZJjC9IAsNlrSA4GHH9j82o7FF1F4J2s38bRy3wZv46MT8X8-QBSpg ``` +## Inject JWT + +You can inject a JWT token into any header using [headers passthrough](./connecting-apps.mdx#headers-passthrough) +configuration and the `{{internal.jwt}}` template variable. This variable will +be replaced with JWT token signed by Teleport JWT CA containing user identity +information like described above. + +For example: + +```yaml +- name: "elasticsearch" + uri: https://localhost:4321 + public_addr: elastic.example.com + rewrite: + headers: + - "Authorization: Bearer {{internal.jwt}}" +``` + ## Validate JWT Teleport provides a JSON Web Key Set (`jwks`) endpoint to verify that the JWT diff --git a/integration/app_integration_test.go b/integration/app_integration_test.go index b3b0043e56bf1..51b243e8eb405 100644 --- a/integration/app_integration_test.go +++ b/integration/app_integration_test.go @@ -17,6 +17,7 @@ limitations under the License. package integration import ( + "bufio" "bytes" "context" "crypto/tls" @@ -313,6 +314,11 @@ func TestAppAccessJWT(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, status) + // Verify JWT token. + verifyJWT(t, pack, token, pack.jwtAppURI) +} + +func verifyJWT(t *testing.T, pack *pack, token, appURI string) { // Get and unmarshal JWKs status, body, err := pack.makeRequest("", http.MethodGet, "/.well-known/jwks.json") require.NoError(t, err) @@ -334,7 +340,7 @@ func TestAppAccessJWT(t *testing.T) { claims, err := key.Verify(jwt.VerifyParams{ Username: pack.username, RawToken: token, - URI: pack.jwtAppURI, + URI: appURI, }) require.NoError(t, err) require.Equal(t, pack.username, claims.Username) @@ -444,6 +450,11 @@ func TestAppAccessRewriteHeadersRoot(t *testing.T) { Name: forward.XForwardedServer, Value: "rewritten-x-forwarded-server-header", }, + // Make sure we can insert JWT token in custom header. + { + Name: "X-JWT", + Value: teleport.TraitInternalJWTVariable, + }, }, }, }, @@ -461,17 +472,25 @@ func TestAppAccessRewriteHeadersRoot(t *testing.T) { }) require.NoError(t, err) require.Equal(t, http.StatusOK, status) - require.Contains(t, resp, "X-Teleport-Cluster: root") - require.Contains(t, resp, "X-External-Env: production") - require.Contains(t, resp, "Host: example.com") - require.Contains(t, resp, "X-Existing: rewritten-existing-header") - require.NotContains(t, resp, "X-Existing: existing") - require.NotContains(t, resp, "rewritten-app-jwt-header") - require.NotContains(t, resp, "rewritten-app-cf-header") - require.NotContains(t, resp, "rewritten-x-forwarded-for-header") - require.NotContains(t, resp, "rewritten-x-forwarded-host-header") - require.NotContains(t, resp, "rewritten-x-forwarded-proto-header") - require.NotContains(t, resp, "rewritten-x-forwarded-server-header") + + // Dumper app just dumps HTTP request so we should be able to read it back. + req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(resp))) + require.NoError(t, err) + require.Equal(t, req.Host, "example.com") + require.Equal(t, req.Header.Get("X-Teleport-Cluster"), "root") + require.Equal(t, req.Header.Get("X-External-Env"), "production") + require.Equal(t, req.Header.Get("X-Existing"), "rewritten-existing-header") + require.NotEqual(t, req.Header.Get(teleport.AppJWTHeader), "rewritten-app-jwt-header") + require.NotEqual(t, req.Header.Get(teleport.AppCFHeader), "rewritten-app-cf-header") + require.NotEqual(t, req.Header.Get(forward.XForwardedFor), "rewritten-x-forwarded-for-header") + require.NotEqual(t, req.Header.Get(forward.XForwardedHost), "rewritten-x-forwarded-host-header") + require.NotEqual(t, req.Header.Get(forward.XForwardedProto), "rewritten-x-forwarded-proto-header") + require.NotEqual(t, req.Header.Get(forward.XForwardedServer), "rewritten-x-forwarded-server-header") + + // Verify JWT tokens. + for _, header := range []string{teleport.AppJWTHeader, teleport.AppCFHeader, "X-JWT"} { + verifyJWT(t, pack, req.Header.Get(header), dumperServer.URL) + } } // TestAppAccessRewriteHeadersLeaf validates that http headers from application diff --git a/lib/jwt/jwt.go b/lib/jwt/jwt.go index 3420436c43977..7bbbe587e4c86 100644 --- a/lib/jwt/jwt.go +++ b/lib/jwt/jwt.go @@ -159,6 +159,7 @@ func (k *Key) Sign(p SignParams) (string, error) { Issuer: k.config.ClusterName, Audience: josejwt.Audience{p.URI}, NotBefore: josejwt.NewNumericDate(k.config.Clock.Now().Add(-10 * time.Second)), + IssuedAt: josejwt.NewNumericDate(k.config.Clock.Now()), Expiry: josejwt.NewNumericDate(p.Expires), }, Username: p.Username, diff --git a/lib/jwt/jwt_test.go b/lib/jwt/jwt_test.go index e654599bfa3df..1aae52468398e 100644 --- a/lib/jwt/jwt_test.go +++ b/lib/jwt/jwt_test.go @@ -17,34 +17,22 @@ limitations under the License. package jwt import ( - "os" "testing" "time" - "github.com/jonboulle/clockwork" - "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/utils" + josejwt "gopkg.in/square/go-jose.v2/jwt" - "gopkg.in/check.v1" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" ) -func TestMain(m *testing.M) { - utils.InitLoggerForTests() - os.Exit(m.Run()) -} - -type Suite struct{} - -var _ = check.Suite(&Suite{}) - -func TestJWT(t *testing.T) { check.TestingT(t) } - -func (s *Suite) TestSignAndVerify(c *check.C) { +func TestSignAndVerify(t *testing.T) { _, privateBytes, err := GenerateKeyPair() - c.Assert(err, check.IsNil) + require.NoError(t, err) privateKey, err := utils.ParsePrivateKey(privateBytes) - c.Assert(err, check.IsNil) + require.NoError(t, err) clock := clockwork.NewFakeClockAt(time.Now()) @@ -55,7 +43,7 @@ func (s *Suite) TestSignAndVerify(c *check.C) { Algorithm: defaults.ApplicationTokenAlgorithm, ClusterName: "example.com", }) - c.Assert(err, check.IsNil) + require.NoError(t, err) // Sign a token with the new key. token, err := key.Sign(SignParams{ @@ -64,7 +52,7 @@ func (s *Suite) TestSignAndVerify(c *check.C) { Expires: clock.Now().Add(1 * time.Minute), URI: "http://127.0.0.1:8080", }) - c.Assert(err, check.IsNil) + require.NoError(t, err) // Verify that the token can be validated and values match expected values. claims, err := key.Verify(VerifyParams{ @@ -72,20 +60,20 @@ func (s *Suite) TestSignAndVerify(c *check.C) { RawToken: token, URI: "http://127.0.0.1:8080", }) - c.Assert(err, check.IsNil) - c.Assert(claims.Username, check.Equals, "foo@example.com") - c.Assert(claims.Roles, check.DeepEquals, []string{"foo", "bar"}) + require.NoError(t, err) + require.Equal(t, claims.Username, "foo@example.com") + require.Equal(t, claims.Roles, []string{"foo", "bar"}) } // TestPublicOnlyVerify checks that a non-signing key used to validate a JWT // can be created. -func (s *Suite) TestPublicOnlyVerify(c *check.C) { +func TestPublicOnlyVerify(t *testing.T) { publicBytes, privateBytes, err := GenerateKeyPair() - c.Assert(err, check.IsNil) + require.NoError(t, err) privateKey, err := utils.ParsePrivateKey(privateBytes) - c.Assert(err, check.IsNil) + require.NoError(t, err) publicKey, err := utils.ParsePublicKey(publicBytes) - c.Assert(err, check.IsNil) + require.NoError(t, err) clock := clockwork.NewFakeClockAt(time.Now()) @@ -95,7 +83,7 @@ func (s *Suite) TestPublicOnlyVerify(c *check.C) { Algorithm: defaults.ApplicationTokenAlgorithm, ClusterName: "example.com", }) - c.Assert(err, check.IsNil) + require.NoError(t, err) // Sign a token with the new key. token, err := key.Sign(SignParams{ @@ -104,7 +92,7 @@ func (s *Suite) TestPublicOnlyVerify(c *check.C) { Expires: clock.Now().Add(1 * time.Minute), URI: "http://127.0.0.1:8080", }) - c.Assert(err, check.IsNil) + require.NoError(t, err) // Create a new key that can only verify tokens and make sure the token // values match the expected values. @@ -113,15 +101,15 @@ func (s *Suite) TestPublicOnlyVerify(c *check.C) { Algorithm: defaults.ApplicationTokenAlgorithm, ClusterName: "example.com", }) - c.Assert(err, check.IsNil) + require.NoError(t, err) claims, err := key.Verify(VerifyParams{ Username: "foo@example.com", URI: "http://127.0.0.1:8080", RawToken: token, }) - c.Assert(err, check.IsNil) - c.Assert(claims.Username, check.Equals, "foo@example.com") - c.Assert(claims.Roles, check.DeepEquals, []string{"foo", "bar"}) + require.NoError(t, err) + require.Equal(t, claims.Username, "foo@example.com") + require.Equal(t, claims.Roles, []string{"foo", "bar"}) // Make sure this key returns an error when trying to sign. _, err = key.Sign(SignParams{ @@ -130,15 +118,15 @@ func (s *Suite) TestPublicOnlyVerify(c *check.C) { Expires: clock.Now().Add(1 * time.Minute), URI: "http://127.0.0.1:8080", }) - c.Assert(err, check.NotNil) + require.Error(t, err) } // TestExpiry checks that token expiration works. -func (s *Suite) TestExpiry(c *check.C) { +func TestExpiry(t *testing.T) { _, privateBytes, err := GenerateKeyPair() - c.Assert(err, check.IsNil) + require.NoError(t, err) privateKey, err := utils.ParsePrivateKey(privateBytes) - c.Assert(err, check.IsNil) + require.NoError(t, err) clock := clockwork.NewFakeClockAt(time.Now()) @@ -149,7 +137,7 @@ func (s *Suite) TestExpiry(c *check.C) { Algorithm: defaults.ApplicationTokenAlgorithm, ClusterName: "example.com", }) - c.Assert(err, check.IsNil) + require.NoError(t, err) // Sign a token with a 1 minute expiration. token, err := key.Sign(SignParams{ @@ -158,7 +146,7 @@ func (s *Suite) TestExpiry(c *check.C) { Expires: clock.Now().Add(1 * time.Minute), URI: "http://127.0.0.1:8080", }) - c.Assert(err, check.IsNil) + require.NoError(t, err) // Verify that the token is still valid. claims, err := key.Verify(VerifyParams{ @@ -166,9 +154,10 @@ func (s *Suite) TestExpiry(c *check.C) { URI: "http://127.0.0.1:8080", RawToken: token, }) - c.Assert(err, check.IsNil) - c.Assert(claims.Username, check.Equals, "foo@example.com") - c.Assert(claims.Roles, check.DeepEquals, []string{"foo", "bar"}) + require.NoError(t, err) + require.Equal(t, claims.Username, "foo@example.com") + require.Equal(t, claims.Roles, []string{"foo", "bar"}) + require.Equal(t, claims.IssuedAt, josejwt.NewNumericDate(clock.Now())) // Advance time by two minutes and verify the token is no longer valid. clock.Advance(2 * time.Minute) @@ -177,5 +166,5 @@ func (s *Suite) TestExpiry(c *check.C) { URI: "http://127.0.0.1:8080", RawToken: token, }) - c.Assert(err, check.NotNil) + require.Error(t, err) } diff --git a/lib/services/role.go b/lib/services/role.go index b74eb4a64555c..0c028b343d895 100644 --- a/lib/services/role.go +++ b/lib/services/role.go @@ -445,7 +445,7 @@ func ApplyValueTraits(val string, traits map[string][]string) ([]string, error) case teleport.TraitLogins, teleport.TraitWindowsLogins, teleport.TraitKubeGroups, teleport.TraitKubeUsers, teleport.TraitDBNames, teleport.TraitDBUsers, - teleport.TraitAWSRoleARNs: + teleport.TraitAWSRoleARNs, teleport.TraitJWT: default: return nil, trace.BadParameter("unsupported variable %q", variable.Name()) } diff --git a/lib/srv/app/session.go b/lib/srv/app/session.go index 6b0e18cb9836c..259b9f7d5bbdb 100644 --- a/lib/srv/app/session.go +++ b/lib/srv/app/session.go @@ -26,6 +26,7 @@ import ( apidefaults "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/types" apievents "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/api/types/wrappers" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" "github.com/gravitational/teleport/lib/events/filesessions" @@ -68,6 +69,13 @@ func (s *Server) newSession(ctx context.Context, identity *tlsca.Identity, app t return nil, trace.Wrap(err) } + // Add JWT token to the traits so it can be used in headers templating. + traits := identity.Traits + if traits == nil { + traits = make(wrappers.Traits) + } + traits[teleport.TraitJWT] = []string{jwt} + // Create a rewriting transport that will be used to forward requests. transport, err := newTransport(s.closeContext, &transportConfig{ @@ -76,7 +84,7 @@ func (s *Server) newSession(ctx context.Context, identity *tlsca.Identity, app t publicPort: s.proxyPort, cipherSuites: s.c.CipherSuites, jwt: jwt, - traits: identity.Traits, + traits: traits, log: s.log, user: identity.Username, })