Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App access JWT improvements #12567

Merged
merged 2 commits into from
May 11, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,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 @@ -558,6 +561,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 @@ -314,6 +315,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 @@ -335,7 +341,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 @@ -445,6 +451,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 @@ -462,17 +473,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: "foo@example.com",
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())

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: "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{
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: "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"})
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