From dcb159a1718fe9744a98333367e7351ead05dd9d Mon Sep 17 00:00:00 2001 From: ajanikow <12255597+ajanikow@users.noreply.github.com> Date: Mon, 2 Jun 2025 17:10:04 +0200 Subject: [PATCH 1/3] [Bugfix] Fix JWT Secret Tail characters --- CHANGELOG.md | 1 + cmd/admin.go | 12 +- integrations/authentication/v1/cache.go | 18 +- .../authentication/v1/implementation.go | 31 ++- pkg/api/api.go | 2 +- pkg/api/auth.go | 4 +- pkg/api/jwt.go | 24 +-- pkg/deployment/context_impl.go | 2 +- pkg/deployment/deployment_pod_sync_test.go | 26 +-- pkg/deployment/deployment_run_test.go | 4 +- pkg/deployment/deployment_suite_test.go | 13 +- .../resources/pod_creator_probes.go | 45 ++++- pkg/deployment/resources/secrets.go | 38 ++-- pkg/handlers/backup/arango_client_impl.go | 2 +- pkg/replication/sync_client.go | 4 +- pkg/util/arangod/client.go | 7 +- pkg/util/k8sutil/secrets.go | 39 +++- pkg/util/mod.go | 8 +- pkg/util/token/claims.go | 47 +++++ pkg/util/token/consts.go | 32 ++++ pkg/util/token/interface.go | 36 ++++ pkg/util/token/method.go | 27 +++ pkg/util/token/mods.go | 55 +++--- pkg/util/token/secret.go | 110 +++++++++++ pkg/util/token/secret_empty.go | 49 +++++ pkg/util/token/secret_set.go | 60 ++++++ pkg/util/token/secrets.go | 68 +++++++ pkg/util/token/token.go | 53 +++--- pkg/util/token/token_test.go | 177 ++++++++++++++---- 29 files changed, 787 insertions(+), 207 deletions(-) create mode 100644 pkg/util/token/claims.go create mode 100644 pkg/util/token/consts.go create mode 100644 pkg/util/token/interface.go create mode 100644 pkg/util/token/method.go create mode 100644 pkg/util/token/secret.go create mode 100644 pkg/util/token/secret_empty.go create mode 100644 pkg/util/token/secret_set.go create mode 100644 pkg/util/token/secrets.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2388cb517..1b81f48cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - (Feature) (Platform) OpenID Integration - (Maintenance) Operator Labeling Skip - (Feature) Optional LocalStorage CRD +- (Bugfix) Align JWT Discovery ## [1.2.48](https://github.com/arangodb/kube-arangodb/tree/1.2.48) (2025-05-08) - (Maintenance) Extend Documentation diff --git a/cmd/admin.go b/cmd/admin.go index 0da5be085..b390fd193 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -38,7 +38,6 @@ import ( meta "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/arangodb-helper/go-certificates" - "github.com/arangodb/go-driver/jwt" "github.com/arangodb/go-driver/v2/connection" api "github.com/arangodb/kube-arangodb/pkg/apis/deployment/v1" @@ -51,6 +50,7 @@ import ( "github.com/arangodb/kube-arangodb/pkg/util/k8sutil" "github.com/arangodb/kube-arangodb/pkg/util/k8sutil/inspector/generic" "github.com/arangodb/kube-arangodb/pkg/util/kclient" + "github.com/arangodb/kube-arangodb/pkg/util/token" ) const ( @@ -405,16 +405,22 @@ func getJWTTokenFromSecrets(ctx context.Context, secrets generic.ReadClient[*cor ctxChild, cancel := globals.GetGlobalTimeouts().Kubernetes().WithTimeout(ctx) defer cancel() - token, err := k8sutil.GetTokenSecret(ctxChild, secrets, name) + secret, err := k8sutil.GetTokenSecret(ctxChild, secrets, name) if err != nil { return nil, errors.WithMessage(err, fmt.Sprintf("failed to get secret \"%s\"", name)) } - bearerToken, err := jwt.CreateArangodJwtAuthorizationHeader(token, "kube-arangodb") + authz, err := token.NewClaims().With( + token.WithDefaultClaims(), + token.WithServerID("kube-arangodb"), + token.WithAllowedPaths("/_api/version"), + ).Sign(secret) if err != nil { return nil, errors.WithMessage(err, fmt.Sprintf("failed to create bearer token from secret \"%s\"", name)) } + bearerToken := fmt.Sprintf("bearer %s", authz) + return JWTAuthentication{key: "Authorization", value: bearerToken}, nil } diff --git a/integrations/authentication/v1/cache.go b/integrations/authentication/v1/cache.go index e4ba0818c..5223a8343 100644 --- a/integrations/authentication/v1/cache.go +++ b/integrations/authentication/v1/cache.go @@ -29,18 +29,13 @@ import ( "time" "github.com/arangodb/kube-arangodb/pkg/util" + "github.com/arangodb/kube-arangodb/pkg/util/token" ) const MaxSize = 128 -type tokens struct { - signingToken []byte - - validationTokens [][]byte -} - -func newCache(cfg Configuration) func(ctx context.Context) (*tokens, time.Duration, error) { - return func(ctx context.Context) (*tokens, time.Duration, error) { +func newCache(cfg Configuration) func(ctx context.Context) (token.Secret, time.Duration, error) { + return func(ctx context.Context) (token.Secret, time.Duration, error) { files, err := os.ReadDir(cfg.Path) if err != nil { return nil, 0, err @@ -87,9 +82,8 @@ func newCache(cfg Configuration) func(ctx context.Context) (*tokens, time.Durati data[id] = ts[keys[id]] } - return &tokens{ - signingToken: ts[keys[0]], - validationTokens: data, - }, cfg.TTL, nil + return token.NewSecretSet(token.NewSecret(ts[keys[0]]), util.FormatList(data, func(a []byte) token.Secret { + return token.NewSecret(a) + })...), cfg.TTL, nil } } diff --git a/integrations/authentication/v1/implementation.go b/integrations/authentication/v1/implementation.go index fe208dc1a..e6c73f035 100644 --- a/integrations/authentication/v1/implementation.go +++ b/integrations/authentication/v1/implementation.go @@ -91,7 +91,7 @@ type implementation struct { cfg Configuration userClient cache.Object[arangodb.Requests] - cache cache.Object[*tokens] + cache cache.Object[token.Secret] } func (i *implementation) Name() string { @@ -189,16 +189,12 @@ func (i *implementation) CreateToken(ctx context.Context, request *pbAuthenticat duration = v } - // Token is validated, we can continue with creation - secret := cache.signingToken - - signedToken, err := token.New(secret, - token.NewClaims().With(token.WithDefaultClaims(), - token.WithCurrentIAT(), - token.WithDuration(duration), - token.WithUsername(user), - token.WithRoles(request.GetRoles()...)), - ) + signedToken, err := token.NewClaims().With( + token.WithDefaultClaims(), + token.WithCurrentIAT(), + token.WithDuration(duration), + token.WithUsername(user), + token.WithRoles(request.GetRoles()...)).Sign(cache) if err != nil { return nil, err } @@ -346,16 +342,15 @@ func (i *implementation) Logout(ctx context.Context, req *pbAuthenticationV1.Log return &pbSharedV1.Empty{}, nil } -func (i *implementation) extractTokenDetails(cache *tokens, t string) (string, []string, time.Duration, error) { +func (i *implementation) extractTokenDetails(cache token.Secret, t string) (string, []string, time.Duration, error) { // Let's check if token is signed properly - - p, err := token.ParseWithAny(t, cache.validationTokens...) + p, err := cache.Validate(t) if err != nil { return "", nil, 0, err } user := DefaultAdminUser - if v, ok := p[token.ClaimPreferredUsername]; ok { + if v, ok := p.Claims()[token.ClaimPreferredUsername]; ok { if s, ok := v.(string); ok { user = s } @@ -363,7 +358,9 @@ func (i *implementation) extractTokenDetails(cache *tokens, t string) (string, [ duration := DefaultTokenMaxTTL - if v, ok := p[token.ClaimEXP]; ok { + claims := p.Claims() + + if v, ok := claims[token.ClaimEXP]; ok { switch o := v.(type) { case int64: duration = time.Until(time.Unix(o, 0)) @@ -374,7 +371,7 @@ func (i *implementation) extractTokenDetails(cache *tokens, t string) (string, [ var roles []string - if v, ok := p[token.ClaimRoles]; ok { + if v, ok := claims[token.ClaimRoles]; ok { switch o := v.(type) { case []string: roles = o diff --git a/pkg/api/api.go b/pkg/api/api.go index b2827ec8a..9fac6f5ac 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -79,7 +79,7 @@ func NewServer(cli typedCore.CoreV1Interface, cfg ServerConfig) (*Server, error) return nil, err } - auth := &authorization{jwtSigningKey: jwtSigningKey} + auth := &authorization{secret: jwtSigningKey} s := &Server{ httpServer: &goHttp.Server{ diff --git a/pkg/api/auth.go b/pkg/api/auth.go index 5aba44e0d..28daddb93 100644 --- a/pkg/api/auth.go +++ b/pkg/api/auth.go @@ -36,11 +36,11 @@ import ( ) type authorization struct { - jwtSigningKey string + secret token.Secret } func (a *authorization) isValid(t string) bool { - if _, err := token.Parse(t, []byte(a.jwtSigningKey)); err != nil { + if _, err := a.secret.Validate(t); err != nil { if errors.Is(err, token.NotValidToken) { return false } diff --git a/pkg/api/jwt.go b/pkg/api/jwt.go index 8fc0f29ff..6b5019391 100644 --- a/pkg/api/jwt.go +++ b/pkg/api/jwt.go @@ -25,7 +25,6 @@ import ( "fmt" "time" - jwt "github.com/golang-jwt/jwt/v5" core "k8s.io/api/core/v1" typedCore "k8s.io/client-go/kubernetes/typed/core/v1" @@ -35,32 +34,33 @@ import ( "github.com/arangodb/kube-arangodb/pkg/util/k8sutil" "github.com/arangodb/kube-arangodb/pkg/util/k8sutil/inspector/generic" "github.com/arangodb/kube-arangodb/pkg/util/k8sutil/kerrors" + "github.com/arangodb/kube-arangodb/pkg/util/token" ) // ensureJWT ensure that JWT signing key exists or creates a new one. // It also saves new token into secret if it is not present. // Returns JWT signing key. -func ensureJWT(cli typedCore.CoreV1Interface, cfg ServerConfig) (string, error) { +func ensureJWT(cli typedCore.CoreV1Interface, cfg ServerConfig) (token.Secret, error) { secrets := cli.Secrets(cfg.Namespace) signingKey, err := k8sutil.GetTokenSecret(context.Background(), secrets, cfg.JWTKeySecretName) - if err != nil && kerrors.IsNotFound(err) || signingKey == "" { + if err != nil && kerrors.IsNotFound(err) || !signingKey.Exists() { signingKey, err = createSigningKey(secrets, cfg.JWTKeySecretName) if err != nil { - return "", err + return token.EmptySecret(), err } } else if err != nil { - return "", errors.WithStack(err) + return token.EmptySecret(), errors.WithStack(err) } _, err = k8sutil.GetTokenSecret(context.Background(), secrets, cfg.JWTSecretName) if err != nil && kerrors.IsNotFound(err) { err = generateAndSaveJWT(secrets, cfg) if err != nil { - return "", err + return token.EmptySecret(), err } } else if err != nil { - return "", errors.WithStack(err) + return token.EmptySecret(), errors.WithStack(err) } return signingKey, nil } @@ -69,7 +69,7 @@ func ensureJWT(cli typedCore.CoreV1Interface, cfg ServerConfig) (string, error) // If it is not present, it creates a new key. // The resulting JWT is stored in secrets. func generateAndSaveJWT(secrets generic.InspectorInterface[*core.Secret], cfg ServerConfig) error { - claims := jwt.MapClaims{ + claims := token.Claims{ "iss": fmt.Sprintf("kube-arangodb/%s", cfg.ServerName), "iat": time.Now().Unix(), } @@ -80,18 +80,18 @@ func generateAndSaveJWT(secrets generic.InspectorInterface[*core.Secret], cfg Se return err } -func createSigningKey(secrets generic.ModClient[*core.Secret], keySecretName string) (string, error) { +func createSigningKey(secrets generic.ModClient[*core.Secret], keySecretName string) (token.Secret, error) { signingKey := make([]byte, 32) _, err := util.Rand().Read(signingKey) if err != nil { - return "", errors.WithStack(err) + return token.EmptySecret(), errors.WithStack(err) } err = globals.GetGlobalTimeouts().Kubernetes().RunWithTimeout(context.Background(), func(ctxChild context.Context) error { return k8sutil.CreateTokenSecret(ctxChild, secrets, keySecretName, string(signingKey), nil) }) if err != nil { - return "", errors.WithStack(err) + return token.EmptySecret(), errors.WithStack(err) } - return string(signingKey), nil + return token.NewSecrets(), nil } diff --git a/pkg/deployment/context_impl.go b/pkg/deployment/context_impl.go index 40944a04a..e049e5bfa 100644 --- a/pkg/deployment/context_impl.go +++ b/pkg/deployment/context_impl.go @@ -295,7 +295,7 @@ func (d *Deployment) getJWTToken() (string, bool) { func (d *Deployment) GetSyncServerClient(ctx context.Context, group api.ServerGroup, id string) (client.API, error) { // Fetch monitoring token secretName := d.GetSpec().Sync.Monitoring.GetTokenSecretName() - monitoringToken, err := k8sutil.GetTokenSecret(ctx, d.GetCachedStatus().Secret().V1().Read(), secretName) + monitoringToken, err := k8sutil.GetTokenSecretString(ctx, d.GetCachedStatus().Secret().V1().Read(), secretName) if err != nil { d.log.Err(err).Str("secret-name", secretName).Debug("Failed to get sync monitoring secret") return nil, errors.WithStack(err) diff --git a/pkg/deployment/deployment_pod_sync_test.go b/pkg/deployment/deployment_pod_sync_test.go index 6ad73113b..6aa140f57 100644 --- a/pkg/deployment/deployment_pod_sync_test.go +++ b/pkg/deployment/deployment_pod_sync_test.go @@ -1,7 +1,7 @@ // // DISCLAIMER // -// Copyright 2016-2024 ArangoDB GmbH, Cologne, Germany +// Copyright 2016-2025 ArangoDB GmbH, Cologne, Germany // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -227,7 +227,7 @@ func TestEnsurePod_Sync_Master(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncMasters, firstSyncMaster) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) @@ -316,7 +316,7 @@ func TestEnsurePod_Sync_Master(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncMasters, firstSyncMaster) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) @@ -420,7 +420,7 @@ func TestEnsurePod_Sync_Master(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncMasters, firstSyncMaster) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) @@ -521,7 +521,7 @@ func TestEnsurePod_Sync_Master(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncMasters, firstSyncMaster) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) @@ -622,7 +622,7 @@ func TestEnsurePod_Sync_Master(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncMasters, firstSyncMaster) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) @@ -726,7 +726,7 @@ func TestEnsurePod_Sync_Master(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncMasters, firstSyncMaster) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) @@ -825,7 +825,7 @@ func TestEnsurePod_Sync_Master(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncMasters, firstSyncMaster) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) @@ -931,7 +931,7 @@ func TestEnsurePod_Sync_Master(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncMasters, firstSyncMaster) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) @@ -1047,7 +1047,7 @@ func TestEnsurePod_Sync_Master(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncMasters, firstSyncMaster) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) @@ -1161,7 +1161,7 @@ func TestEnsurePod_Sync_Master(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncMasters, firstSyncMaster) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) @@ -1272,7 +1272,7 @@ func TestEnsurePod_Sync_Worker(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncWorkers, firstSyncWorker) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) @@ -1367,7 +1367,7 @@ func TestEnsurePod_Sync_Worker(t *testing.T) { testCase.createTestPodData(deployment, api.ServerGroupSyncWorkers, firstSyncWorker) name := testCase.ArangoDeployment.Spec.Sync.Monitoring.GetTokenSecretName() - auth, err := k8sutil.GetTokenSecret(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) + auth, err := k8sutil.GetTokenSecretString(context.Background(), deployment.GetCachedStatus().Secret().V1().Read(), name) require.NoError(t, err) testCase.ExpectedPod.Spec.Containers[0].LivenessProbe = createTestLivenessProbe("", true, "bearer "+auth, shared.ServerPortName) diff --git a/pkg/deployment/deployment_run_test.go b/pkg/deployment/deployment_run_test.go index e161d3ee9..1044308dd 100644 --- a/pkg/deployment/deployment_run_test.go +++ b/pkg/deployment/deployment_run_test.go @@ -1,7 +1,7 @@ // // DISCLAIMER // -// Copyright 2016-2024 ArangoDB GmbH, Cologne, Germany +// Copyright 2016-2025 ArangoDB GmbH, Cologne, Germany // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -298,6 +298,6 @@ func compareSpec(t *testing.T, a, b core.PodSpec) { bj, err := json.Marshal(b) require.NoError(t, err) - require.Equal(t, string(aj), string(bj)) + require.Equal(t, string(bj), string(aj)) require.Equal(t, ac, bc) } diff --git a/pkg/deployment/deployment_suite_test.go b/pkg/deployment/deployment_suite_test.go index 5fcfe033f..dc3310d84 100644 --- a/pkg/deployment/deployment_suite_test.go +++ b/pkg/deployment/deployment_suite_test.go @@ -40,7 +40,6 @@ import ( "github.com/arangodb-helper/go-helper/pkg/arangod/conn" driver "github.com/arangodb/go-driver" - "github.com/arangodb/go-driver/jwt" api "github.com/arangodb/kube-arangodb/pkg/apis/deployment/v1" shared "github.com/arangodb/kube-arangodb/pkg/apis/shared" @@ -57,6 +56,7 @@ import ( "github.com/arangodb/kube-arangodb/pkg/util/k8sutil/probes" kresources "github.com/arangodb/kube-arangodb/pkg/util/k8sutil/resources" "github.com/arangodb/kube-arangodb/pkg/util/kclient" + "github.com/arangodb/kube-arangodb/pkg/util/token" ) const ( @@ -125,7 +125,16 @@ func createTestToken(deployment *Deployment, testCase *testCaseStruct, paths []s return "", err } - return jwt.CreateArangodJwtAuthorizationHeaderAllowedPaths(s, "kube-arangodb", paths) + t, err := token.NewClaims().With( + token.WithDefaultClaims(), + token.WithServerID("kube-arangodb"), + token.WithAllowedPaths(paths...), + ).Sign(s) + if err != nil { + return "", err + } + + return fmt.Sprintf("bearer %s", t), nil } func modTestLivenessProbe(mode string, secure bool, authorization string, port string, mod func(*core.Probe)) *core.Probe { diff --git a/pkg/deployment/resources/pod_creator_probes.go b/pkg/deployment/resources/pod_creator_probes.go index 87b7ec30f..3d2c3618b 100644 --- a/pkg/deployment/resources/pod_creator_probes.go +++ b/pkg/deployment/resources/pod_creator_probes.go @@ -1,7 +1,7 @@ // // DISCLAIMER // -// Copyright 2016-2023 ArangoDB GmbH, Cologne, Germany +// Copyright 2016-2025 ArangoDB GmbH, Cologne, Germany // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -21,13 +21,12 @@ package resources import ( + "fmt" "math" "time" core "k8s.io/api/core/v1" - "github.com/arangodb/go-driver/jwt" - api "github.com/arangodb/kube-arangodb/pkg/apis/deployment/v1" "github.com/arangodb/kube-arangodb/pkg/deployment/features" "github.com/arangodb/kube-arangodb/pkg/deployment/pod" @@ -35,6 +34,7 @@ import ( "github.com/arangodb/kube-arangodb/pkg/util/errors" "github.com/arangodb/kube-arangodb/pkg/util/k8sutil" "github.com/arangodb/kube-arangodb/pkg/util/k8sutil/probes" + "github.com/arangodb/kube-arangodb/pkg/util/token" ) type Probe interface { @@ -294,10 +294,15 @@ func (r *Resources) probeBuilderLivenessCore(spec api.DeploymentSpec, group api. if err != nil { return nil, errors.WithStack(err) } - authorization, err = jwt.CreateArangodJwtAuthorizationHeaderAllowedPaths(secretData, "kube-arangodb", []string{"/_api/version"}) + authz, err := token.NewClaims().With( + token.WithDefaultClaims(), + token.WithServerID("kube-arangodb"), + token.WithAllowedPaths("/_api/version")).Sign(secretData) if err != nil { return nil, errors.WithStack(err) } + + authorization = fmt.Sprintf("bearer %s", authz) } return &probes.HTTPProbeConfig{ LocalPath: "/_api/version", @@ -315,10 +320,16 @@ func (r *Resources) probeBuilderStartupCore(spec api.DeploymentSpec, group api.S if err != nil { return nil, errors.WithStack(err) } - authorization, err = jwt.CreateArangodJwtAuthorizationHeaderAllowedPaths(secretData, "kube-arangodb", []string{"/_api/version"}) + authz, err := token.NewClaims().With( + token.WithDefaultClaims(), + token.WithServerID("kube-arangodb"), + token.WithAllowedPaths("/_api/version"), + ).Sign(secretData) if err != nil { return nil, errors.WithStack(err) } + + authorization = fmt.Sprintf("bearer %s", authz) } return &probes.HTTPProbeConfig{ LocalPath: "/_api/version", @@ -407,10 +418,16 @@ func (r *Resources) probeBuilderReadinessCore(spec api.DeploymentSpec, _ api.Ser if err != nil { return nil, errors.WithStack(err) } - authorization, err = jwt.CreateArangodJwtAuthorizationHeaderAllowedPaths(secretData, "kube-arangodb", []string{localPath}) + authz, err := token.NewClaims().With( + token.WithDefaultClaims(), + token.WithServerID("kube-arangodb"), + token.WithAllowedPaths(localPath), + ).Sign(secretData) if err != nil { return nil, errors.WithStack(err) } + + authorization = fmt.Sprintf("bearer %s", authz) } probeCfg := &probes.HTTPProbeConfig{ LocalPath: localPath, @@ -439,10 +456,16 @@ func (r *Resources) probeBuilderLivenessSync(spec api.DeploymentSpec, group api. if err != nil { return nil, errors.WithStack(err) } - authorization, err = jwt.CreateArangodJwtAuthorizationHeaderAllowedPaths(secretData, "kube-arangodb", []string{"/_api/version"}) + authz, err := token.NewClaims().With( + token.WithDefaultClaims(), + token.WithServerID("kube-arangodb"), + token.WithAllowedPaths("/_api/version"), + ).Sign(secretData) if err != nil { return nil, errors.WithStack(err) } + + authorization = fmt.Sprintf("bearer %s", authz) } else { // Don't have a probe return nil, nil @@ -470,10 +493,16 @@ func (r *Resources) probeBuilderStartupSync(spec api.DeploymentSpec, group api.S if err != nil { return nil, errors.WithStack(err) } - authorization, err = jwt.CreateArangodJwtAuthorizationHeaderAllowedPaths(secretData, "kube-arangodb", []string{"/_api/version"}) + authz, err := token.NewClaims().With( + token.WithDefaultClaims(), + token.WithServerID("kube-arangodb"), + token.WithAllowedPaths("/_api/version"), + ).Sign(secretData) if err != nil { return nil, errors.WithStack(err) } + + authorization = fmt.Sprintf("bearer %s", authz) } else { // Don't have a probe return nil, nil diff --git a/pkg/deployment/resources/secrets.go b/pkg/deployment/resources/secrets.go index 78adcd288..5a9167b96 100644 --- a/pkg/deployment/resources/secrets.go +++ b/pkg/deployment/resources/secrets.go @@ -27,7 +27,6 @@ import ( "fmt" "time" - jwt "github.com/golang-jwt/jwt/v5" core "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" meta "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -424,15 +423,18 @@ func AppendKeyfileToKeyfolder(ctx context.Context, cachedStatus inspectorInterfa } var ( - exporterTokenClaims = jwt.MapClaims{ - token.ClaimISS: token.ClaimISSValue, - "server_id": "exporter", - "allowed_paths": []interface{}{"/_admin/statistics", "/_admin/statistics-description", + exporterTokenClaimsMods = []util.ModR[token.Claims]{ + token.WithDefaultClaims(), + token.WithServerID("exporter"), + token.WithAllowedPaths( + "/_admin/statistics", + "/_admin/statistics-description", shared.ArangoExporterInternalEndpoint, shared.ArangoExporterInternalEndpointV2, shared.ArangoExporterUsageEndpoint, shared.ArangoExporterStatusEndpoint, - shared.ArangoExporterClusterHealthEndpoint}, + shared.ArangoExporterClusterHealthEndpoint, + ), } ) @@ -444,9 +446,10 @@ func (r *Resources) ensureExporterTokenSecret(ctx context.Context, cachedStatus return err } else if update { // Create secret + claims := token.NewClaims().With(exporterTokenClaimsMods...) if !exists { owner := r.context.GetAPIObject().AsOwner() - err = k8sutil.CreateJWTFromSecret(ctx, cachedStatus.Secret().V1().Read(), secrets, tokenSecretName, secretSecretName, exporterTokenClaims, &owner) + err = k8sutil.CreateJWTFromSecret(ctx, cachedStatus.Secret().V1().Read(), secrets, tokenSecretName, secretSecretName, claims, &owner) if kerrors.IsAlreadyExists(err) { // Secret added while we tried it also return nil @@ -455,7 +458,7 @@ func (r *Resources) ensureExporterTokenSecret(ctx context.Context, cachedStatus return errors.WithStack(err) } } else { - err = k8sutil.UpdateJWTFromSecret(ctx, cachedStatus.Secret().V1().Read(), secrets, tokenSecretName, secretSecretName, exporterTokenClaims) + err = k8sutil.UpdateJWTFromSecret(ctx, cachedStatus.Secret().V1().Read(), secrets, tokenSecretName, secretSecretName, claims) if kerrors.IsAlreadyExists(err) { // Secret added while we tried it also return nil @@ -490,15 +493,14 @@ func (r *Resources) ensureExporterTokenSecretCreateRequired(cachedStatus inspect return true, true, errors.WithStack(err) } - token, err := token.Parse(string(data), []byte(secret)) - + tokenClaims, err := secret.Validate(string(data)) if err != nil { return true, true, nil } - tokenClaims := jwt.MapClaims(token) + expectedClaims := token.NewClaims().With(exporterTokenClaimsMods...) - return !equality.Semantic.DeepDerivative(tokenClaims, exporterTokenClaims), true, nil + return !equality.Semantic.DeepDerivative(tokenClaims, expectedClaims), true, nil } } @@ -551,26 +553,26 @@ func (r *Resources) ensureClientAuthCACertificateSecret(ctx context.Context, cac } // getJWTSecret loads the JWT secret from a Secret configured in apiObject.Spec.Authentication.JWTSecretName. -func (r *Resources) getJWTSecret(spec api.DeploymentSpec) (string, error) { +func (r *Resources) getJWTSecret(spec api.DeploymentSpec) (token.Secret, error) { if !spec.IsAuthenticated() { - return "", nil + return token.EmptySecret(), nil } secretName := spec.Authentication.GetJWTSecretName() s, err := k8sutil.GetTokenSecret(context.Background(), r.context.ACS().CurrentClusterCache().Secret().V1().Read(), secretName) if err != nil { r.log.Str("section", "jwt").Err(err).Str("secret-name", secretName).Debug("Failed to get JWT secret") - return "", errors.WithStack(err) + return token.EmptySecret(), errors.WithStack(err) } return s, nil } // getSyncJWTSecret loads the JWT secret used for syncmasters from a Secret configured in apiObject.Spec.Sync.Authentication.JWTSecretName. -func (r *Resources) getSyncJWTSecret(spec api.DeploymentSpec) (string, error) { +func (r *Resources) getSyncJWTSecret(spec api.DeploymentSpec) (token.Secret, error) { secretName := spec.Sync.Authentication.GetJWTSecretName() s, err := k8sutil.GetTokenSecret(context.Background(), r.context.ACS().CurrentClusterCache().Secret().V1().Read(), secretName) if err != nil { r.log.Str("section", "jwt").Err(err).Str("secret-name", secretName).Debug("Failed to get sync JWT secret") - return "", errors.WithStack(err) + return token.EmptySecret(), errors.WithStack(err) } return s, nil } @@ -578,7 +580,7 @@ func (r *Resources) getSyncJWTSecret(spec api.DeploymentSpec) (string, error) { // getSyncMonitoringToken loads the token secret used for monitoring sync masters & workers. func (r *Resources) getSyncMonitoringToken(spec api.DeploymentSpec) (string, error) { secretName := spec.Sync.Monitoring.GetTokenSecretName() - s, err := k8sutil.GetTokenSecret(context.Background(), r.context.ACS().CurrentClusterCache().Secret().V1().Read(), secretName) + s, err := k8sutil.GetTokenSecretString(context.Background(), r.context.ACS().CurrentClusterCache().Secret().V1().Read(), secretName) if err != nil { r.log.Str("section", "jwt").Err(err).Str("secret-name", secretName).Debug("Failed to get sync monitoring secret") return "", errors.WithStack(err) diff --git a/pkg/handlers/backup/arango_client_impl.go b/pkg/handlers/backup/arango_client_impl.go index 266925f58..c4d6e2b2f 100644 --- a/pkg/handlers/backup/arango_client_impl.go +++ b/pkg/handlers/backup/arango_client_impl.go @@ -171,7 +171,7 @@ func (ac *arangoClientBackupImpl) Get(backupID driver.BackupID) (driver.BackupMe func (ac *arangoClientBackupImpl) getCredentialsFromSecret(ctx context.Context, secretName string) (interface{}, error) { ctxChild, cancel := globals.GetGlobalTimeouts().Kubernetes().WithTimeout(ctx) defer cancel() - token, err := k8sutil.GetTokenSecret(ctxChild, ac.kubecli.CoreV1().Secrets(ac.backup.Namespace), secretName) + token, err := k8sutil.GetTokenSecretString(ctxChild, ac.kubecli.CoreV1().Secrets(ac.backup.Namespace), secretName) if err != nil { return nil, err } diff --git a/pkg/replication/sync_client.go b/pkg/replication/sync_client.go index d95f17f8a..63ac5712d 100644 --- a/pkg/replication/sync_client.go +++ b/pkg/replication/sync_client.go @@ -1,7 +1,7 @@ // // DISCLAIMER // -// Copyright 2016-2023 ArangoDB GmbH, Cologne, Germany +// Copyright 2016-2025 ArangoDB GmbH, Cologne, Germany // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -64,7 +64,7 @@ func (dr *DeploymentReplication) createSyncMasterClient(epSpec api.EndpointSpec) } } else if authJWTSecretName != "" { var err error - jwtSecret, err = k8sutil.GetTokenSecret(context.TODO(), secrets, authJWTSecretName) + jwtSecret, err = k8sutil.GetTokenSecretString(context.TODO(), secrets, authJWTSecretName) if err != nil { return nil, errors.WithStack(err) } diff --git a/pkg/util/arangod/client.go b/pkg/util/arangod/client.go index ff1e63f0b..e05c4ebe8 100644 --- a/pkg/util/arangod/client.go +++ b/pkg/util/arangod/client.go @@ -22,6 +22,7 @@ package arangod import ( "context" + "fmt" "net" goHttp "net/http" "strconv" @@ -30,7 +31,6 @@ import ( "github.com/arangodb/go-driver" "github.com/arangodb/go-driver/http" - "github.com/arangodb/go-driver/jwt" "github.com/arangodb/go-driver/util/connection/wrappers/async" api "github.com/arangodb/kube-arangodb/pkg/apis/deployment/v1" @@ -39,6 +39,7 @@ import ( "github.com/arangodb/kube-arangodb/pkg/util/globals" operatorHTTP "github.com/arangodb/kube-arangodb/pkg/util/http" "github.com/arangodb/kube-arangodb/pkg/util/k8sutil" + "github.com/arangodb/kube-arangodb/pkg/util/token" ) type ( @@ -176,11 +177,11 @@ func createArangodClientAuthentication(ctx context.Context, cli typedCore.CoreV1 if err != nil { return nil, errors.WithStack(err) } - jwt, err := jwt.CreateArangodJwtAuthorizationHeader(s, "kube-arangodb") + jwt, err := token.NewClaims().With(token.WithDefaultClaims(), token.WithServerID("kube-arangodb")).Sign(s) if err != nil { return nil, errors.WithStack(err) } - return driver.RawAuthentication(jwt), nil + return driver.RawAuthentication(fmt.Sprintf("bearer %s", jwt)), nil } } else { // Authentication is not enabled. diff --git a/pkg/util/k8sutil/secrets.go b/pkg/util/k8sutil/secrets.go index 84302b938..d364b4b16 100644 --- a/pkg/util/k8sutil/secrets.go +++ b/pkg/util/k8sutil/secrets.go @@ -1,7 +1,7 @@ // // DISCLAIMER // -// Copyright 2016-2024 ArangoDB GmbH, Cologne, Germany +// Copyright 2016-2025 ArangoDB GmbH, Cologne, Germany // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -262,17 +262,17 @@ func ValidateTokenFromSecret(s *core.Secret) error { return nil } -// GetTokenSecret loads the token secret from a Secret with given name. -func GetTokenSecret(ctx context.Context, secrets generic.ReadClient[*core.Secret], secretName string) (string, error) { +// GetTokenSecretString loads the token secret from a Secret with given name. +func GetTokenSecretString(ctx context.Context, secrets generic.ReadClient[*core.Secret], secretName string) (string, error) { s, err := secrets.Get(ctx, secretName, meta.GetOptions{}) if err != nil { return "", errors.WithStack(err) } - return GetTokenFromSecret(s) + return GetTokenFromSecretString(s) } -// GetTokenFromSecret loads the token secret from a Secret with given name. -func GetTokenFromSecret(s *core.Secret) (string, error) { +// GetTokenFromSecretString loads the token secret from a Secret with given name. +func GetTokenFromSecretString(s *core.Secret) (string, error) { // Take the first data from the token key data, found := s.Data[constants.SecretKeyToken] if !found { @@ -281,6 +281,25 @@ func GetTokenFromSecret(s *core.Secret) (string, error) { return string(data), nil } +// GetTokenSecret loads the token secret from a Secret with given name. +func GetTokenSecret(ctx context.Context, secrets generic.ReadClient[*core.Secret], secretName string) (token.Secret, error) { + s, err := secrets.Get(ctx, secretName, meta.GetOptions{}) + if err != nil { + return nil, errors.WithStack(err) + } + return GetTokenFromSecret(s) +} + +// GetTokenFromSecret loads the token secret from a Secret with given name. +func GetTokenFromSecret(s *core.Secret) (token.Secret, error) { + // Take the first data from the token key + data, found := s.Data[constants.SecretKeyToken] + if !found { + return nil, errors.WithStack(errors.Errorf("No '%s' data found in secret '%s'", constants.SecretKeyToken, s.GetName())) + } + return token.NewSecret(data), nil +} + // CreateTokenSecret creates a secret with given name in given namespace // with a given token as value. func CreateTokenSecret(ctx context.Context, secrets generic.ModClient[*core.Secret], secretName, token string, @@ -318,13 +337,13 @@ func UpdateTokenSecret(ctx context.Context, secrets generic.ModClient[*core.Secr // CreateJWTFromSecret creates a JWT using the secret stored in secretSecretName and stores the // result in a new secret called tokenSecretName -func CreateJWTFromSecret(ctx context.Context, cachedSecrets generic.ReadClient[*core.Secret], secrets generic.ModClient[*core.Secret], tokenSecretName, secretSecretName string, claims map[string]interface{}, ownerRef *meta.OwnerReference) error { +func CreateJWTFromSecret(ctx context.Context, cachedSecrets generic.ReadClient[*core.Secret], secrets generic.ModClient[*core.Secret], tokenSecretName, secretSecretName string, claims token.Claims, ownerRef *meta.OwnerReference) error { secret, err := GetTokenSecret(ctx, cachedSecrets, secretSecretName) if err != nil { return errors.WithStack(err) } - signedToken, err := token.New([]byte(secret), claims) + signedToken, err := claims.Sign(secret) if err != nil { return errors.WithStack(err) } @@ -336,7 +355,7 @@ func CreateJWTFromSecret(ctx context.Context, cachedSecrets generic.ReadClient[* // UpdateJWTFromSecret updates a JWT using the secret stored in secretSecretName and stores the // result in a new secret called tokenSecretName -func UpdateJWTFromSecret(ctx context.Context, cachedSecrets generic.ReadClient[*core.Secret], secrets generic.ModClient[*core.Secret], tokenSecretName, secretSecretName string, claims map[string]interface{}) error { +func UpdateJWTFromSecret(ctx context.Context, cachedSecrets generic.ReadClient[*core.Secret], secrets generic.ModClient[*core.Secret], tokenSecretName, secretSecretName string, claims token.Claims) error { current, err := cachedSecrets.Get(ctx, tokenSecretName, meta.GetOptions{}) if err != nil { return errors.WithStack(err) @@ -347,7 +366,7 @@ func UpdateJWTFromSecret(ctx context.Context, cachedSecrets generic.ReadClient[* return errors.WithStack(err) } - signedToken, err := token.New([]byte(secret), claims) + signedToken, err := claims.Sign(secret) if err != nil { return errors.WithStack(err) } diff --git a/pkg/util/mod.go b/pkg/util/mod.go index 2851090f4..63212438c 100644 --- a/pkg/util/mod.go +++ b/pkg/util/mod.go @@ -92,8 +92,6 @@ func ApplyModsEP1[T, P1 any](in *T, p1 P1, mods ...ModEP1[T, P1]) error { return nil } -func emptyModR[T any](z T) T { return z } - type ModR[T any] func(in T) T func (m ModR[T]) Optional() ModR[T] { @@ -106,9 +104,9 @@ func (m ModR[T]) Optional() ModR[T] { func ApplyModsR[T any](in T, mods ...ModR[T]) T { for _, mod := range mods { - if mod != nil { - mod(in) - } + in = mod(in) } return in } + +func emptyModR[T any](z T) T { return z } diff --git a/pkg/util/token/claims.go b/pkg/util/token/claims.go new file mode 100644 index 000000000..71c455b9e --- /dev/null +++ b/pkg/util/token/claims.go @@ -0,0 +1,47 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package token + +import ( + jwt "github.com/golang-jwt/jwt/v5" + + "github.com/arangodb/kube-arangodb/pkg/util" +) + +func NewClaims() Claims { + return Claims{} +} + +type Claims jwt.MapClaims + +func (t Claims) With(mods ...util.ModR[Claims]) Claims { + q := t + + if q == nil { + q = Claims{} + } + + return util.ApplyModsR(q, mods...) +} + +func (t Claims) Sign(secret Secret) (string, error) { + return secret.Sign(DefaultSigningMethod(), t) +} diff --git a/pkg/util/token/consts.go b/pkg/util/token/consts.go new file mode 100644 index 000000000..a8b85099b --- /dev/null +++ b/pkg/util/token/consts.go @@ -0,0 +1,32 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package token + +const ( + ClaimISS = "iss" + ClaimISSValue = "arangodb" + ClaimEXP = "exp" + ClaimIAT = "iat" + ClaimPreferredUsername = "preferred_username" + ClaimRoles = "roles" + ClaimServerID = "server_id" + ClaimAllowedPaths = "allowed_paths" +) diff --git a/pkg/util/token/interface.go b/pkg/util/token/interface.go new file mode 100644 index 000000000..9971e331f --- /dev/null +++ b/pkg/util/token/interface.go @@ -0,0 +1,36 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package token + +import jwt "github.com/golang-jwt/jwt/v5" + +type Secret interface { + Hash() string + + Sign(method jwt.SigningMethod, claims Claims) (string, error) + Validate(token string) (Token, error) + + Exists() bool +} + +type Token interface { + Claims() Claims +} diff --git a/pkg/util/token/method.go b/pkg/util/token/method.go new file mode 100644 index 000000000..01ad30ac1 --- /dev/null +++ b/pkg/util/token/method.go @@ -0,0 +1,27 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package token + +import jwt "github.com/golang-jwt/jwt/v5" + +func DefaultSigningMethod() jwt.SigningMethod { + return jwt.SigningMethodHS256 +} diff --git a/pkg/util/token/mods.go b/pkg/util/token/mods.go index c34898104..7af71e6b1 100644 --- a/pkg/util/token/mods.go +++ b/pkg/util/token/mods.go @@ -24,13 +24,15 @@ import ( "time" jwt "github.com/golang-jwt/jwt/v5" + + "github.com/arangodb/kube-arangodb/pkg/util" ) var defaultTokenClaims = jwt.MapClaims{ ClaimISS: ClaimISSValue, } -func WithDefaultClaims() Mod { +func WithDefaultClaims() util.ModR[Claims] { return func(in Claims) Claims { for k, v := range defaultTokenClaims { if _, ok := in[k]; !ok { @@ -42,42 +44,49 @@ func WithDefaultClaims() Mod { } } -func WithUsername(username string) Mod { - return func(in Claims) Claims { - in[ClaimPreferredUsername] = username - return in - } +func WithUsername(username string) util.ModR[Claims] { + return WithKey(ClaimPreferredUsername, username) } -func WithCurrentIAT() Mod { - return func(in Claims) Claims { - in[ClaimIAT] = time.Now().Unix() - return in - } +func WithCurrentIAT() util.ModR[Claims] { + return WithIAT(time.Now()) } -func WithIAT(time time.Time) Mod { - return func(in Claims) Claims { - in[ClaimIAT] = time.Unix() - return in - } +func WithIAT(time time.Time) util.ModR[Claims] { + return WithKey(ClaimIAT, time.Unix()) } -func WithDuration(dur time.Duration) Mod { - return func(in Claims) Claims { - in[ClaimEXP] = time.Now().Add(dur).Unix() - return in +func WithDuration(dur time.Duration) util.ModR[Claims] { + return WithExp(time.Now().Add(dur)) +} + +func WithExp(time time.Time) util.ModR[Claims] { + return WithKey(ClaimEXP, time.Unix()) +} + +func WithServerID(id string) util.ModR[Claims] { + return WithKey(ClaimServerID, id) +} + +func WithAllowedPaths(paths ...string) util.ModR[Claims] { + if len(paths) == 0 { + return emptyClaimsMod } + return WithKey(ClaimAllowedPaths, paths) +} + +func emptyClaimsMod(in Claims) Claims { + return in } -func WithExp(time time.Time) Mod { +func WithKey(key string, value interface{}) util.ModR[Claims] { return func(in Claims) Claims { - in[ClaimEXP] = time.Unix() + in[key] = value return in } } -func WithRoles(roles ...string) Mod { +func WithRoles(roles ...string) util.ModR[Claims] { return func(in Claims) Claims { in[ClaimRoles] = roles return in diff --git a/pkg/util/token/secret.go b/pkg/util/token/secret.go new file mode 100644 index 000000000..84a100bac --- /dev/null +++ b/pkg/util/token/secret.go @@ -0,0 +1,110 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package token + +import ( + jwt "github.com/golang-jwt/jwt/v5" + "github.com/pkg/errors" + + "github.com/arangodb/kube-arangodb/pkg/util" +) + +const DefaultTokenSecretSize = 64 + +var secretTrimCharacters = []byte{ + ' ', + '\t', + '\n', + '\r', +} + +func isTrimCharacter(char byte) bool { + for _, b := range secretTrimCharacters { + if b == char { + return true + } + } + return false +} + +func trimSecret(in []byte) []byte { + for { + if len(in) == 0 { + return in + } + + if isTrimCharacter(in[0]) { + in = in[1:] + continue + } + + if isTrimCharacter(in[len(in)-1]) { + in = in[:len(in)-1] + continue + } + + return in + } +} + +func NewSecret(data []byte) Secret { + return NewSecretWithSize(data, DefaultTokenSecretSize) +} + +func NewSecretWithSize(data []byte, size int) Secret { + data = trimSecret(data) + + if len(data) == 0 { + return emptySecret{} + } + + r := make([]byte, size) + + copy(r, data) + + return secret(r) +} + +type secret []byte + +func (s secret) Exists() bool { + return true +} + +func (s secret) Sign(method jwt.SigningMethod, claims Claims) (string, error) { + token := jwt.NewWithClaims(method, jwt.MapClaims(claims)) + + // Sign and get the complete encoded token as a string using the secret + signedToken, err := token.SignedString([]byte(s)) + if err != nil { + return "", errors.WithStack(err) + } + + return signedToken, nil +} + +func (s secret) Validate(token string) (Token, error) { + return Validate(token, s) +} + +func (s secret) Hash() string { + return util.SHA256(s) +} diff --git a/pkg/util/token/secret_empty.go b/pkg/util/token/secret_empty.go new file mode 100644 index 000000000..1641fe982 --- /dev/null +++ b/pkg/util/token/secret_empty.go @@ -0,0 +1,49 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package token + +import ( + jwt "github.com/golang-jwt/jwt/v5" + + "github.com/arangodb/kube-arangodb/pkg/util/errors" +) + +func EmptySecret() Secret { + return emptySecret{} +} + +type emptySecret struct{} + +func (e emptySecret) Hash() string { + return "" +} + +func (e emptySecret) Sign(method jwt.SigningMethod, claims Claims) (string, error) { + return "", errors.Errorf("no token found") +} + +func (e emptySecret) Validate(token string) (Token, error) { + return nil, jwt.ErrSignatureInvalid +} + +func (e emptySecret) Exists() bool { + return false +} diff --git a/pkg/util/token/secret_set.go b/pkg/util/token/secret_set.go new file mode 100644 index 000000000..f4a19398d --- /dev/null +++ b/pkg/util/token/secret_set.go @@ -0,0 +1,60 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package token + +import ( + jwt "github.com/golang-jwt/jwt/v5" + + "github.com/arangodb/kube-arangodb/pkg/util" +) + +func NewSecretSet(main Secret, secondary ...Secret) Secret { + return secretSet{ + main: main, + secondary: Secrets(secondary), + } +} + +type secretSet struct { + main Secret + + secondary Secret +} + +func (s secretSet) Exists() bool { + return s.main.Exists() || s.secondary.Exists() +} + +func (s secretSet) Hash() string { + return util.SHA256FromStringArray(s.main.Hash(), s.secondary.Hash()) +} + +func (s secretSet) Sign(method jwt.SigningMethod, claims Claims) (string, error) { + return s.main.Sign(method, claims) +} + +func (s secretSet) Validate(token string) (Token, error) { + if c, err := s.main.Validate(token); err == nil { + return c, nil + } + + return s.secondary.Validate(token) +} diff --git a/pkg/util/token/secrets.go b/pkg/util/token/secrets.go new file mode 100644 index 000000000..4b084afc1 --- /dev/null +++ b/pkg/util/token/secrets.go @@ -0,0 +1,68 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package token + +import ( + jwt "github.com/golang-jwt/jwt/v5" + + "github.com/arangodb/kube-arangodb/pkg/util" + "github.com/arangodb/kube-arangodb/pkg/util/errors" +) + +func NewSecrets(secrets ...Secret) Secret { + return Secrets(secrets) +} + +type Secrets []Secret + +func (s Secrets) Exists() bool { + for _, k := range s { + if k.Exists() { + return true + } + } + + return false +} + +func (s Secrets) Hash() string { + return util.SHA256FromStringArray(util.FormatList(s, func(a Secret) string { + return a.Hash() + })...) +} + +func (s Secrets) Sign(method jwt.SigningMethod, claims Claims) (string, error) { + return "", errors.Errorf("secrets signing method not supported") +} + +func (s Secrets) Validate(token string) (Token, error) { + for _, secret := range s { + if c, err := secret.Validate(token); err == nil { + return c, nil + } else { + if !IsSignatureInvalidError(err) { + return nil, err + } + } + } + + return nil, jwt.ErrSignatureInvalid +} diff --git a/pkg/util/token/token.go b/pkg/util/token/token.go index 76f8f4be9..e03e16458 100644 --- a/pkg/util/token/token.go +++ b/pkg/util/token/token.go @@ -26,45 +26,36 @@ import ( "github.com/arangodb/kube-arangodb/pkg/util/errors" ) -const ( - ClaimISS = "iss" - ClaimISSValue = "arangodb" - ClaimEXP = "exp" - ClaimIAT = "iat" - ClaimPreferredUsername = "preferred_username" - ClaimRoles = "roles" -) - -type Mod func(in Claims) Claims +func Validate(t string, secret []byte) (Token, error) { + token, err := jwt.Parse(t, func(token *jwt.Token) (i interface{}, err error) { + return secret, nil + }, jwt.WithIssuedAt()) + if err != nil { + return nil, err + } -func NewClaims() Claims { - return Claims{} + return newToken(token) } -type Claims jwt.MapClaims - -func (t Claims) With(mods ...Mod) Claims { - q := t - - if q == nil { - q = Claims{} +func newToken(in *jwt.Token) (Token, error) { + tokenClaims, ok := in.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.Errorf("Invalid token provided") } - for _, mod := range mods { - q = mod(q) + if !in.Valid { + return nil, jwt.ErrSignatureInvalid } - return q + return token{ + claims: Claims(tokenClaims), + }, nil } -func New(secret []byte, claims map[string]interface{}) (string, error) { - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)) - - // Sign and get the complete encoded token as a string using the secret - signedToken, err := token.SignedString(secret) - if err != nil { - return "", errors.WithStack(err) - } +type token struct { + claims Claims +} - return signedToken, nil +func (t token) Claims() Claims { + return t.claims } diff --git a/pkg/util/token/token_test.go b/pkg/util/token/token_test.go index 4362580d0..0f22f29b5 100644 --- a/pkg/util/token/token_test.go +++ b/pkg/util/token/token_test.go @@ -29,101 +29,196 @@ import ( "github.com/arangodb/kube-arangodb/pkg/util" ) -func secret() []byte { - d := make([]byte, 32) - - util.Rand().Read(d) - - return d +func testSecretToken() []byte { + return testSecretTokenSized(64) } -func sign(t *testing.T, secret []byte, mods ...Mod) string { - token, err := New(secret, NewClaims().With(mods...)) - require.NoError(t, err) - return token +func testSecretTokenSized(size int) []byte { + var z = make([]byte, size) + for id := range z { + z[id] = byte('A' + util.Rand().Intn('Z'-'A')) + } + return z } func Test_TokenSign(t *testing.T) { t.Run("Signed properly token", func(t *testing.T) { - s := secret() + s := NewSecret(testSecretToken()) - token := sign(t, s, WithCurrentIAT()) + token, err := NewClaims().With(WithCurrentIAT()).Sign(s) + require.NoError(t, err) - claims, err := Parse(token, s) + claims, err := s.Validate(token) require.NoError(t, err) - require.Contains(t, claims, ClaimIAT) + require.Contains(t, claims.Claims(), ClaimIAT) }) t.Run("Signed in future token", func(t *testing.T) { - s := secret() + s := NewSecret(testSecretToken()) - token := sign(t, s, WithIAT(time.Now().Add(time.Hour))) + token, err := NewClaims().With(WithIAT(time.Now().Add(time.Hour))).Sign(s) + require.NoError(t, err) - _, err := Parse(token, s) + _, err = s.Validate(token) require.EqualError(t, err, "token has invalid claims: token used before issued") }) t.Run("Expired", func(t *testing.T) { - s := secret() + s := NewSecret(testSecretToken()) - token := sign(t, s, WithIAT(time.Now().Add(-time.Hour)), WithDuration(-time.Second)) + token, err := NewClaims().With(WithIAT(time.Now().Add(-time.Hour)), WithDuration(-time.Second)).Sign(s) + require.NoError(t, err) - _, err := Parse(token, s) + _, err = s.Validate(token) require.EqualError(t, err, "token has invalid claims: token is expired") }) t.Run("Invalid secret", func(t *testing.T) { - s := secret() - s2 := secret() + s := NewSecret(testSecretToken()) + s2 := NewSecret(testSecretToken()) - token := sign(t, s, WithCurrentIAT()) + token, err := NewClaims().With(WithCurrentIAT()).Sign(s) + require.NoError(t, err) - _, err := Parse(token, s2) + _, err = s2.Validate(token) require.EqualError(t, err, "token signature is invalid: signature is invalid") require.True(t, IsSignatureInvalidError(err)) }) t.Run("Signed properly token with first", func(t *testing.T) { - s := secret() - s2 := secret() + s := NewSecret(testSecretToken()) + s2 := NewSecret(testSecretToken()) - token := sign(t, s, WithCurrentIAT()) + sm := NewSecretSet(s, s2) - claims, err := ParseWithAny(token, s, s2) + token, err := NewClaims().With(WithCurrentIAT()).Sign(s) require.NoError(t, err) - require.Contains(t, claims, ClaimIAT) + claims, err := sm.Validate(token) + require.NoError(t, err) + + require.Contains(t, claims.Claims(), ClaimIAT) }) t.Run("Signed properly token with second", func(t *testing.T) { - s := secret() - s2 := secret() + s := NewSecret(testSecretToken()) + s2 := NewSecret(testSecretToken()) + + sm := NewSecretSet(s, s2) - token := sign(t, s, WithCurrentIAT()) + token, err := NewClaims().With(WithCurrentIAT()).Sign(s2) + require.NoError(t, err) - claims, err := ParseWithAny(token, s2, s) + claims, err := sm.Validate(token) require.NoError(t, err) - require.Contains(t, claims, ClaimIAT) + require.Contains(t, claims.Claims(), ClaimIAT) }) t.Run("Without secrets", func(t *testing.T) { - s := secret() + s := NewSecret(testSecretToken()) + ns := NewSecrets() - token := sign(t, s, WithCurrentIAT()) + token, err := NewClaims().With(WithCurrentIAT()).Sign(s) + require.NoError(t, err) - _, err := ParseWithAny(token) + _, err = ns.Validate(token) require.True(t, IsSignatureInvalidError(err)) }) t.Run("Expired with second", func(t *testing.T) { - s := secret() - s2 := secret() + s := NewSecret(testSecretToken()) + s2 := NewSecret(testSecretToken()) + + ns := NewSecretSet(s, s2) - token := sign(t, s, WithIAT(time.Now().Add(-time.Hour)), WithDuration(-time.Second)) + token, err := NewClaims().With(WithIAT(time.Now().Add(-time.Hour)), WithDuration(-time.Second)).Sign(s2) + require.NoError(t, err) - _, err := ParseWithAny(token, s2, s) + _, err = ns.Validate(token) require.EqualError(t, err, "token has invalid claims: token is expired") }) + + t.Run("Ensure token gets trimmed", func(t *testing.T) { + b := testSecretTokenSized(128) + s := NewSecret(b) + s2 := NewSecret(b[:64]) + + token, err := NewClaims().With(WithCurrentIAT()).Sign(s) + require.NoError(t, err) + + claims, err := s2.Validate(token) + require.NoError(t, err) + + require.Contains(t, claims.Claims(), ClaimIAT) + }) + + t.Run("Ensure token gets filled", func(t *testing.T) { + b := testSecretTokenSized(16) + bs := make([]byte, DefaultTokenSecretSize) + copy(bs, b) + s := NewSecret(b) + s2 := NewSecret(bs) + + token, err := NewClaims().With(WithCurrentIAT()).Sign(s) + require.NoError(t, err) + + claims, err := s2.Validate(token) + require.NoError(t, err) + + require.Contains(t, claims.Claims(), ClaimIAT) + }) + + t.Run("Ensure token gets removed prefix", func(t *testing.T) { + b := testSecretTokenSized(16) + bs := make([]byte, DefaultTokenSecretSize) + bs[0] = ' ' + copy(bs[1:], b) + s := NewSecret(b) + s2 := NewSecret(bs) + + token, err := NewClaims().With(WithCurrentIAT()).Sign(s) + require.NoError(t, err) + + claims, err := s2.Validate(token) + require.NoError(t, err) + + require.Contains(t, claims.Claims(), ClaimIAT) + }) + + t.Run("Ensure token gets removed postfix", func(t *testing.T) { + b := testSecretTokenSized(16) + bs := make([]byte, 17) + bs[16] = ' ' + copy(bs, b) + s := NewSecret(b) + s2 := NewSecret(bs) + + token, err := NewClaims().With(WithCurrentIAT()).Sign(s) + require.NoError(t, err) + + claims, err := s2.Validate(token) + require.NoError(t, err) + + require.Contains(t, claims.Claims(), ClaimIAT) + }) + + t.Run("Ensure token gets trimmed", func(t *testing.T) { + b := testSecretTokenSized(16) + bs := make([]byte, 18) + bs[17] = ' ' + bs[0] = ' ' + copy(bs[1:], b) + s := NewSecret(b) + s2 := NewSecret(bs) + + token, err := NewClaims().With(WithCurrentIAT()).Sign(s) + require.NoError(t, err) + + claims, err := s2.Validate(token) + require.NoError(t, err) + + require.Contains(t, claims.Claims(), ClaimIAT) + }) } From 02096395b3675436f6275b5f9633bfd94547b92e Mon Sep 17 00:00:00 2001 From: ajanikow <12255597+ajanikow@users.noreply.github.com> Date: Tue, 3 Jun 2025 10:00:10 +0200 Subject: [PATCH 2/3] Iter --- pkg/api/jwt.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/api/jwt.go b/pkg/api/jwt.go index 6b5019391..1fdf8a5d0 100644 --- a/pkg/api/jwt.go +++ b/pkg/api/jwt.go @@ -81,7 +81,7 @@ func generateAndSaveJWT(secrets generic.InspectorInterface[*core.Secret], cfg Se } func createSigningKey(secrets generic.ModClient[*core.Secret], keySecretName string) (token.Secret, error) { - signingKey := make([]byte, 32) + signingKey := make([]byte, 64) _, err := util.Rand().Read(signingKey) if err != nil { return token.EmptySecret(), errors.WithStack(err) @@ -93,5 +93,5 @@ func createSigningKey(secrets generic.ModClient[*core.Secret], keySecretName str if err != nil { return token.EmptySecret(), errors.WithStack(err) } - return token.NewSecrets(), nil + return token.NewSecret(signingKey), nil } From 701bb68672121c827c5c1c728e7135dfb7ba6d6c Mon Sep 17 00:00:00 2001 From: ajanikow <12255597+ajanikow@users.noreply.github.com> Date: Tue, 3 Jun 2025 10:17:30 +0200 Subject: [PATCH 3/3] Iter --- cmd/admin.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/admin.go b/cmd/admin.go index b390fd193..ec1390c24 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -401,7 +401,7 @@ func createClient(endpoints []string, certCA *x509.CertPool, auth connection.Aut } // getJWTTokenFromSecrets returns token from the secret. -func getJWTTokenFromSecrets(ctx context.Context, secrets generic.ReadClient[*core.Secret], name string) (connection.Authentication, error) { +func getJWTTokenFromSecrets(ctx context.Context, secrets generic.ReadClient[*core.Secret], name string, paths ...string) (connection.Authentication, error) { ctxChild, cancel := globals.GetGlobalTimeouts().Kubernetes().WithTimeout(ctx) defer cancel() @@ -410,11 +410,16 @@ func getJWTTokenFromSecrets(ctx context.Context, secrets generic.ReadClient[*cor return nil, errors.WithMessage(err, fmt.Sprintf("failed to get secret \"%s\"", name)) } - authz, err := token.NewClaims().With( + claims := token.NewClaims().With( token.WithDefaultClaims(), token.WithServerID("kube-arangodb"), - token.WithAllowedPaths("/_api/version"), - ).Sign(secret) + ) + + if len(paths) > 0 { + claims = claims.With(token.WithAllowedPaths(paths...)) + } + + authz, err := claims.Sign(secret) if err != nil { return nil, errors.WithMessage(err, fmt.Sprintf("failed to create bearer token from secret \"%s\"", name)) }