Skip to content

Commit

Permalink
App access JWT header improvements (#12589)
Browse files Browse the repository at this point in the history
  • Loading branch information
r0mant authored May 12, 2022
1 parent adf6b39 commit e845151
Show file tree
Hide file tree
Showing 8 changed files with 106 additions and 58 deletions.
7 changes: 7 additions & 0 deletions constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion docs/pages/application-access/guides/connecting-apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```
Expand All @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions docs/pages/application-access/guides/jwt.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 31 additions & 12 deletions integration/app_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
package integration

import (
"bufio"
"bytes"
"context"
"crypto/tls"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
},
},
},
},
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/jwt/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
75 changes: 32 additions & 43 deletions lib/jwt/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand All @@ -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{
Expand All @@ -64,28 +52,28 @@ 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{
Username: "[email protected]",
RawToken: token,
URI: "http://127.0.0.1:8080",
})
c.Assert(err, check.IsNil)
c.Assert(claims.Username, check.Equals, "[email protected]")
c.Assert(claims.Roles, check.DeepEquals, []string{"foo", "bar"})
require.NoError(t, err)
require.Equal(t, claims.Username, "[email protected]")
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())

Expand All @@ -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{
Expand All @@ -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.
Expand All @@ -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: "[email protected]",
URI: "http://127.0.0.1:8080",
RawToken: token,
})
c.Assert(err, check.IsNil)
c.Assert(claims.Username, check.Equals, "[email protected]")
c.Assert(claims.Roles, check.DeepEquals, []string{"foo", "bar"})
require.NoError(t, err)
require.Equal(t, claims.Username, "[email protected]")
require.Equal(t, claims.Roles, []string{"foo", "bar"})

// Make sure this key returns an error when trying to sign.
_, err = key.Sign(SignParams{
Expand All @@ -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())

Expand All @@ -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{
Expand All @@ -158,17 +146,18 @@ 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{
Username: "[email protected]",
URI: "http://127.0.0.1:8080",
RawToken: token,
})
c.Assert(err, check.IsNil)
c.Assert(claims.Username, check.Equals, "[email protected]")
c.Assert(claims.Roles, check.DeepEquals, []string{"foo", "bar"})
require.NoError(t, err)
require.Equal(t, claims.Username, "[email protected]")
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)
Expand All @@ -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)
}
2 changes: 1 addition & 1 deletion lib/services/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
10 changes: 9 additions & 1 deletion lib/srv/app/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand All @@ -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,
})
Expand Down

0 comments on commit e845151

Please sign in to comment.