Skip to content

Commit

Permalink
feat: JWT auth (#2620)
Browse files Browse the repository at this point in the history
* feat(wip): jwt auth

* feat: more jwt auth impl

* chore: add interceptor tests

* chore: check auth when required

* chore(wip): working on ITs

* chore: rm unneeded pem

* chore: fix ITs

* chore: set allowed signing algs

* chore: add some config file validation and tests for jwt

* chore: ITs pass

* chore: fix login link

* chore: rm unused field

* chore: fix linter errs

* chore: fix build

* chore: PR feedback/validate exp; cuefmt

* chore: switch to public_key_file

* chore: try to fix readonly ITs

* chore: add test for jwks

* chore: add back ClientTokenProvider

* chore: comment on supported signing algs; add EdDSA

* chore: delete broken test for now

* chore: update readme
  • Loading branch information
markphelps authored Jan 9, 2024
1 parent 451ab64 commit f22446f
Show file tree
Hide file tree
Showing 36 changed files with 1,042 additions and 314 deletions.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<img src="logo.svg" alt="Flipt" width=275 height=96 />
</p>

<p align="center">An enterprise-ready, GRPC powered, GitOps enabled, feature management solution</p>
<p align="center">An enterprise-ready, GRPC powered, GitOps enabled, CloudNative, feature management solution</p>

<hr />

Expand Down Expand Up @@ -89,7 +89,7 @@ Flipt supports use cases such as:

## Values

- 🔒 **Security** - No [SSO Tax](https://sso.tax/). HTTPS, OIDC, OAuth, Service, and API Token authentication methods supported out of the box.
- 🔒 **Security** - No [SSO Tax](https://sso.tax/). HTTPS, OIDC, JWT, OAuth, K8s Service Token, and API Token authentication methods supported out of the box.
- 🎛️ **Control** - No data leaves your servers and you don't have to open your systems to the outside world to communicate with Flipt. It all runs within your existing infrastructure.
- 🚀 **Speed** - Since Flipt is co-located with your existing services, you do not have to communicate across the internet which can add excessive latency and slow down your applications.
-**Simplicity** - Flipt is a single binary with no external dependencies by default.
Expand All @@ -101,10 +101,10 @@ Flipt supports use cases such as:

- Stand-alone, single binary that's easy to run and [configure](https://www.flipt.io/docs/configuration/overview)
- Ability to create advanced distribution rules to target segments of users
- Modern, mobile-friendly 📱 UI and debug console with dark mode 🌙
- Modern UI and debug console with dark mode 🌙
- Import and export to allow storing your data as code
- Works with [Prometheus](https://prometheus.io/) and [OpenTelemetry](https://opentelemetry.io/) out of the box 🔋
- [Filesystem, Object, Git, and OCI declarative storage backends](https://www.flipt.io/docs/configuration/storage#declarative) to support GitOps workflows and more.
- CloudNative [Filesystem, Object, Git, and OCI declarative storage backends](https://www.flipt.io/docs/configuration/storage#declarative) to support GitOps workflows and more.
- Audit logging with Webhook support to track changes to your data

Are we missing a feature that you'd like to see? [Let us know!](https://features.flipt.io)
Expand Down Expand Up @@ -291,6 +291,8 @@ We welcome contributions of any kind, including but not limited to bug fixes, fe

[![Open in Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new/?repo=flipt-io/flipt)

[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/flipt-io/flipt)

<br clear="both"/>

## Examples
Expand Down
107 changes: 96 additions & 11 deletions build/testing/integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,43 @@ package testing

import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"os"
"path"
"strings"
"time"

"dagger.io/dagger"
"github.com/containerd/containerd/platforms"
"github.com/go-jose/go-jose/v3"
jjwt "github.com/go-jose/go-jose/v3/jwt"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/hashicorp/cap/jwt"
"golang.org/x/sync/errgroup"
)

const bootstrapToken = "s3cr3t"

var priv *rsa.PrivateKey

func init() {
// Generate a key to sign JWTs with throughout most test cases.
// It can be slow sometimes to generate a 4096-bit RSA key, so we only do it once.
var err error
priv, err = rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
panic(err)
}
}

var (
protocolPorts = map[string]int{"http": 8080, "grpc": 9000}
replacer = strings.NewReplacer(" ", "-", "/", "-")
Expand Down Expand Up @@ -72,14 +93,26 @@ type authConfig int

const (
noAuth authConfig = iota
authNoNamespace
authNamespaced
staticAuth
staticAuthNamespaced
jwtAuth
)

func (a authConfig) enabled() bool {
return a != noAuth
}

func (a authConfig) method() string {
switch a {
case staticAuth, staticAuthNamespaced:
return "static"
case jwtAuth:
return "jwt"
default:
return ""
}
}

func Integration(ctx context.Context, client *dagger.Client, base, flipt *dagger.Container, caseNames ...string) error {
cases, err := filterCases(caseNames...)
if err != nil {
Expand All @@ -99,7 +132,8 @@ func Integration(ctx context.Context, client *dagger.Client, base, flipt *dagger

for _, namespace := range []string{"", "production"} {
for protocol, port := range protocolPorts {
for _, auth := range []authConfig{noAuth, authNoNamespace, authNamespaced} {
for _, auth := range []authConfig{noAuth, staticAuth, staticAuthNamespaced, jwtAuth} {
auth := auth
config := testConfig{
name: fmt.Sprintf("%s namespace %s", strings.ToUpper(protocol), namespace),
namespace: namespace,
Expand All @@ -111,10 +145,12 @@ func Integration(ctx context.Context, client *dagger.Client, base, flipt *dagger
switch auth {
case noAuth:
config.name = fmt.Sprintf("%s without auth", config.name)
case authNoNamespace:
config.name = fmt.Sprintf("%s with auth no namespaced token", config.name)
case authNamespaced:
config.name = fmt.Sprintf("%s with auth namespaced token", config.name)
case staticAuth:
config.name = fmt.Sprintf("%s with static auth token", config.name)
case staticAuthNamespaced:
config.name = fmt.Sprintf("%s with static auth namespaced token", config.name)
case jwtAuth:
config.name = fmt.Sprintf("%s with jwt auth", config.name)
}

configs = append(configs, config)
Expand All @@ -133,10 +169,24 @@ func Integration(ctx context.Context, client *dagger.Client, base, flipt *dagger

flipt := flipt
if config.auth.enabled() {
bytes, err := x509.MarshalPKIXPublicKey(priv.Public())
if err != nil {
return err
}

bytes = pem.EncodeToMemory(&pem.Block{
Type: "public key",
Bytes: bytes,
})

flipt = flipt.
WithEnvVariable("FLIPT_AUTHENTICATION_REQUIRED", "true").
WithEnvVariable("FLIPT_AUTHENTICATION_METHODS_TOKEN_ENABLED", "true").
WithEnvVariable("FLIPT_AUTHENTICATION_METHODS_TOKEN_BOOTSTRAP_TOKEN", bootstrapToken)
WithEnvVariable("FLIPT_AUTHENTICATION_METHODS_TOKEN_BOOTSTRAP_TOKEN", bootstrapToken).
WithEnvVariable("FLIPT_AUTHENTICATION_METHODS_JWT_ENABLED", "true").
WithNewFile("/etc/flipt/jwt.pem", dagger.ContainerWithNewFileOpts{Contents: string(bytes)}).
WithEnvVariable("FLIPT_AUTHENTICATION_METHODS_JWT_PUBLIC_KEY_FILE", "/etc/flipt/jwt.pem").
WithEnvVariable("FLIPT_AUTHENTICATION_METHODS_JWT_VALIDATE_CLAIMS_ISSUER", "https://flipt.io")
}

name := strings.ToLower(replacer.Replace(fmt.Sprintf("flipt-test-%s-config-%s", caseName, config.name)))
Expand Down Expand Up @@ -482,9 +532,28 @@ func suite(ctx context.Context, dir string, base, flipt *dagger.Container, conf
}

if conf.auth.enabled() {
flags = append(flags, "--flipt-token", bootstrapToken)
if conf.auth == authNamespaced {
flags = append(flags, "--flipt-create-namespaced-token")
flags = append(flags, "--flipt-token-type", conf.auth.method())

switch conf.auth.method() {
case "static":
flags = append(flags, "--flipt-token", bootstrapToken)
if conf.auth == staticAuthNamespaced {
flags = append(flags, "--flipt-create-namespaced-token")
}
case "jwt":
var (
now = time.Now()
nowUnix = float64(now.Unix())
futureUnix = float64(now.Add(2 * jjwt.DefaultLeeway).Unix())
)

token := signJWT(priv, map[string]interface{}{
"iss": "https://flipt.io",
"iat": nowUnix,
"exp": futureUnix,
})

flags = append(flags, "--flipt-token", token)
}
}

Expand Down Expand Up @@ -559,3 +628,19 @@ func gcs(ctx context.Context, client *dagger.Client, base, flipt *dagger.Contain

return suite(ctx, "readonly", base, flipt.WithExec(nil), conf)
}

func signJWT(key crypto.PrivateKey, claims interface{}) string {
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.SignatureAlgorithm(string(jwt.RS256)), Key: key},
(&jose.SignerOptions{}).WithType("JWT"),
)

raw, err := jjwt.Signed(sig).
Claims(claims).
CompactSerialize()
if err != nil {
panic(err)
}

return raw
}
10 changes: 5 additions & 5 deletions build/testing/integration/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func API(t *testing.T, ctx context.Context, client sdk.SDK, opts integration.Tes

t.Run("Namespaces", func(t *testing.T) {
if !namespaceIsDefault(namespace) {
if authConfig == integration.AuthNamespaced {
if authConfig == integration.StaticTokenAuthNamespaced {
t.Log("Create namespace.")

_, err := client.Flipt().CreateNamespace(ctx, &flipt.CreateNamespaceRequest{
Expand Down Expand Up @@ -506,7 +506,7 @@ func API(t *testing.T, ctx context.Context, client sdk.SDK, opts integration.Tes
assert.Equal(t, int32(3), updatedRuleThree.Rank)

// ensure you can not link flags and segments from different namespaces.
if !namespaceIsDefault(namespace) && authConfig != integration.AuthNamespaced {
if !namespaceIsDefault(namespace) && authConfig != integration.StaticTokenAuthNamespaced {
t.Log(`Ensure that rules can only link entities in the same namespace.`)
_, err = client.Flipt().CreateRule(ctx, &flipt.CreateRuleRequest{
FlagKey: "test",
Expand Down Expand Up @@ -586,7 +586,7 @@ func API(t *testing.T, ctx context.Context, client sdk.SDK, opts integration.Tes
})
require.NoError(t, err)

if !namespaceIsDefault(namespace) && authConfig != integration.AuthNamespaced {
if !namespaceIsDefault(namespace) && authConfig != integration.StaticTokenAuthNamespaced {
t.Log(`Ensure that distributions and all entities associated with them are part of same namespace.`)
var (
flagKey = "defaultflag"
Expand Down Expand Up @@ -1339,7 +1339,7 @@ func API(t *testing.T, ctx context.Context, client sdk.SDK, opts integration.Tes
t.Log(`Returns Flipt service information.`)

// meta shouldn't be reachable with a namespace scoped token
if authConfig == integration.AuthNamespaced {
if authConfig == integration.StaticTokenAuthNamespaced {
_, err := client.Meta().GetInfo(ctx)
require.EqualError(t, err, "rpc error: code = Unauthenticated desc = request was not authenticated")
return
Expand Down Expand Up @@ -1392,7 +1392,7 @@ func API(t *testing.T, ctx context.Context, client sdk.SDK, opts integration.Tes
t.Run("Auth", func(t *testing.T) {
t.Run("Self", func(t *testing.T) {
_, err := client.Auth().AuthenticationService().GetAuthenticationSelf(ctx)
if authConfig == integration.AuthNoNamespace {
if authConfig == integration.StaticTokenAuth || authConfig == integration.JWTAuth {
// only valid with a non-scoped token
assert.NoError(t, err)
} else {
Expand Down
16 changes: 12 additions & 4 deletions build/testing/integration/api/authenticated.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ func Authenticated(t *testing.T, client sdk.SDK, opts integration.TestOpts) {
})

t.Run("Get Self", func(t *testing.T) {
if !opts.AuthConfig.StaticToken() {
t.Skip("Skipping test for non-static token authentication")
}

authn, err := client.Auth().AuthenticationService().GetAuthenticationSelf(ctx)

if opts.AuthConfig == integration.AuthNamespaced {
if opts.AuthConfig == integration.StaticTokenAuthNamespaced {
require.EqualError(t, err, "rpc error: code = Unauthenticated desc = request was not authenticated")
return
}
Expand All @@ -51,7 +55,7 @@ func Authenticated(t *testing.T, client sdk.SDK, opts integration.TestOpts) {
})

// a namespaced token should not be able to create any other tokens
if opts.AuthConfig == integration.AuthNamespaced {
if opts.AuthConfig == integration.StaticTokenAuthNamespaced {
require.EqualError(t, err, "rpc error: code = Unauthenticated desc = request was not authenticated")
return
}
Expand All @@ -78,7 +82,7 @@ func Authenticated(t *testing.T, client sdk.SDK, opts integration.TestOpts) {
})

// a namespaced token should not be able to create any other tokens
if opts.AuthConfig == integration.AuthNamespaced {
if opts.AuthConfig == integration.StaticTokenAuthNamespaced {
require.EqualError(t, err, "rpc error: code = Unauthenticated desc = request was not authenticated")
return
}
Expand All @@ -93,11 +97,15 @@ func Authenticated(t *testing.T, client sdk.SDK, opts integration.TestOpts) {
})

t.Run("Expire Self", func(t *testing.T) {
if !opts.AuthConfig.StaticToken() {
t.Skip("Skipping test for non-static token authentication")
}

err := client.Auth().AuthenticationService().ExpireAuthenticationSelf(ctx, &auth.ExpireAuthenticationSelfRequest{
ExpiresAt: flipt.Now(),
})

if opts.AuthConfig == integration.AuthNamespaced {
if opts.AuthConfig == integration.StaticTokenAuthNamespaced {
require.EqualError(t, err, "rpc error: code = Unauthenticated desc = request was not authenticated")
return
}
Expand Down
Loading

0 comments on commit f22446f

Please sign in to comment.