Skip to content

Commit b3297ac

Browse files
Merge commit from fork
Signed-off-by: Kent Rancourt <kent.rancourt@gmail.com> Co-authored-by: Kent Rancourt <kent.rancourt@gmail.com>
1 parent 754a435 commit b3297ac

5 files changed

Lines changed: 146 additions & 43 deletions

File tree

cmd/controlplane/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ func (o *apiOptions) run(ctx context.Context) error {
8383
return fmt.Errorf("error getting Kubernetes client REST config: %w", err)
8484
}
8585
kubernetes.ConfigureQPSBurst(ctx, restCfg, o.QPS, o.Burst)
86+
serverCfg.RestConfig = restCfg
8687

8788
kubeClientOptions := kubernetes.ClientOptions{}
8889
if serverCfg.OIDCConfig != nil {

pkg/cli/cmd/server/server.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ func (o *serverOptions) run(ctx context.Context) error {
9292

9393
srv := server.NewServer(
9494
apiconfig.ServerConfig{
95-
LocalMode: true,
95+
RestConfig: restCfg,
96+
LocalMode: true,
9697
},
9798
client,
9899
rbac.NewKubernetesRolesDatabase(client),

pkg/server/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"time"
77

88
"github.com/kelseyhightower/envconfig"
9+
"k8s.io/client-go/rest"
910

1011
"github.com/akuity/kargo/pkg/os"
1112
"github.com/akuity/kargo/pkg/server/dex"
@@ -32,6 +33,7 @@ type ServerConfig struct {
3233
AnalysisRunLogToken string
3334
AnalysisRunLogHTTPHeaders map[string]string
3435
ClusterSecretNamespace string
36+
RestConfig *rest.Config
3537
}
3638

3739
func ServerConfigFromEnv() ServerConfig {

pkg/server/option/auth.go

Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/hashicorp/go-cleanhttp"
2121
corev1 "k8s.io/api/core/v1"
2222
"k8s.io/apimachinery/pkg/types"
23+
"k8s.io/client-go/rest"
2324
libClient "sigs.k8s.io/controller-runtime/pkg/client"
2425

2526
kargoapi "github.com/akuity/kargo/api/v1alpha1"
@@ -50,16 +51,11 @@ type authInterceptor struct {
5051
claims jwt.Claims,
5152
) (*jwt.Token, []string, error)
5253
verifyKargoIssuedTokenFn func(rawToken string) bool
53-
verifyIDPIssuedTokenFn func(
54-
ctx context.Context,
55-
rawToken string,
56-
) (claims, error)
57-
oidcTokenVerifyFn goOIDCIDTokenVerifyFn
58-
oidcExtractClaimsFn func(*oidc.IDToken) (claims, error)
59-
listServiceAccountsFn func(
60-
ctx context.Context,
61-
c claims,
62-
) (map[string]map[types.NamespacedName]struct{}, error)
54+
verifyIDPIssuedTokenFn func(ctx context.Context, rawToken string) (claims, error)
55+
verifyKubernetesTokenFn func(ctx context.Context, rawToken string) error
56+
oidcTokenVerifyFn goOIDCIDTokenVerifyFn
57+
oidcExtractClaimsFn func(*oidc.IDToken) (claims, error)
58+
listServiceAccountsFn func(ctx context.Context, c claims) (map[string]map[types.NamespacedName]struct{}, error)
6359
}
6460

6561
// goOIDCIDTokenVerifyFn is a github.com/coreos/go-oidc/v3/oidc/IDTokenVerifier.Verify() function
@@ -81,6 +77,7 @@ func newAuthInterceptor(
8177
a.parseUnverifiedJWTFn = jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified
8278
a.verifyKargoIssuedTokenFn = a.verifyKargoIssuedToken
8379
a.verifyIDPIssuedTokenFn = a.verifyIDPIssuedToken
80+
a.verifyKubernetesTokenFn = a.verifyKubernetesToken
8481
a.oidcExtractClaimsFn = oidcExtractClaims
8582
a.listServiceAccountsFn = a.listServiceAccounts
8683
return a
@@ -368,22 +365,17 @@ func (a *authInterceptor) authenticate(
368365

369366
// Are we dealing with a JWT?
370367
//
371-
// Note: If this is a JWT, we cannot trust these claims yet because we're not
372-
// verifying the token yet. We use untrustedClaims.Issuer only as a hint as to
373-
// HOW we might be able to verify the token further.
368+
// If not, we no longer assume this is potentially some other form of token
369+
// that the Kubernetes API server might recognize, as that is an increasingly
370+
// unlikely scenario.
371+
//
372+
// If this IS a JWT, we cannot trust these claims yet because we're not
373+
// verifying the token just yet. We use untrustedClaims.Issuer only as a hint
374+
// as to HOW we might be able to verify the token further.
374375
untrustedClaims := jwt.RegisteredClaims{}
375376
if _, _, err := a.parseUnverifiedJWTFn(rawToken, &untrustedClaims); err != nil {
376-
// This token isn't a JWT, so it's probably an opaque bearer token for the
377-
// Kubernetes API server. Just run with it. If we're wrong, Kubernetes API
378-
// calls will simply have auth errors that will bubble back to the client.
379-
return user.ContextWithInfo(
380-
ctx,
381-
user.Info{
382-
BearerToken: rawToken,
383-
},
384-
), nil
377+
return ctx, errors.New("invalid token")
385378
}
386-
387379
logger.Debug("found untrusted claims in token", "claims", untrustedClaims)
388380

389381
// If we get to here, we're dealing with a JWT. It could have been issued:
@@ -451,15 +443,16 @@ func (a *authInterceptor) authenticate(
451443

452444
}
453445

454-
// Case 3 or 4: We don't know how to verify this token. It's probably a token
455-
// issued by the Kubernetes cluster's identity provider. Just run with it. If
456-
// we're wrong, Kubernetes API calls will simply have auth errors that will
457-
// bubble back to the client.
446+
// Case 3 or 4: We don't know how to verify this token. It's possibly a token
447+
// issued by the Kubernetes cluster's identity provider.
458448

459-
logger.Debug(
460-
"could not verify token; assuming it might have been issued by " +
461-
"Kubernetes cluster identity provider",
462-
)
449+
// Test whether Kubernetes recognizes this token by making a request to /api
450+
logger.Debug("could not verify token; checking if Kubernetes recognizes it")
451+
if err := a.verifyKubernetesTokenFn(ctx, rawToken); err != nil {
452+
logger.Debug("token not recognized by Kubernetes", "error", err)
453+
return ctx, errors.New("invalid token")
454+
}
455+
logger.Debug("token recognized by Kubernetes")
463456

464457
return user.ContextWithInfo(
465458
ctx,
@@ -529,3 +522,42 @@ func oidcExtractClaims(token *oidc.IDToken) (claims, error) {
529522
err := token.Claims(&c)
530523
return c, err
531524
}
525+
526+
// verifyKubernetesToken tests whether the Kubernetes API server recognizes the
527+
// provided token by making a GET request to the /api endpoint. This is a
528+
// lightweight check that doesn't require any specific permissions.
529+
func (a *authInterceptor) verifyKubernetesToken(
530+
ctx context.Context,
531+
rawToken string,
532+
) error {
533+
if a.cfg.RestConfig == nil { // This shouldn't happen, but just in case...
534+
return errors.New("Kubernetes REST config is not available") // nolint: staticcheck
535+
}
536+
537+
transport, err := rest.TransportFor(a.cfg.RestConfig)
538+
if err != nil {
539+
return fmt.Errorf("create transport: %w", err)
540+
}
541+
542+
apiURL := strings.TrimSuffix(a.cfg.RestConfig.Host, "/") + "/api"
543+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
544+
if err != nil {
545+
return fmt.Errorf("create request: %w", err)
546+
}
547+
req.Header.Set("Authorization", "Bearer "+rawToken)
548+
549+
resp, err := (&http.Client{Transport: transport}).Do(req)
550+
if err != nil {
551+
return fmt.Errorf("execute request: %w", err)
552+
}
553+
defer resp.Body.Close()
554+
555+
if resp.StatusCode != http.StatusOK {
556+
return fmt.Errorf(
557+
"unexpected response from Kubernetes API server: %d",
558+
resp.StatusCode,
559+
)
560+
}
561+
562+
return nil
563+
}

pkg/server/option/auth_test.go

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/golang-jwt/jwt/v5"
1515
"github.com/stretchr/testify/require"
1616
"k8s.io/apimachinery/pkg/types"
17+
"k8s.io/client-go/rest"
1718

1819
"github.com/akuity/kargo/pkg/server/config"
1920
"github.com/akuity/kargo/pkg/server/dex"
@@ -52,6 +53,7 @@ func TestNewAuthInterceptor(t *testing.T) {
5253
require.NotNil(t, a.parseUnverifiedJWTFn)
5354
require.NotNil(t, a.verifyKargoIssuedTokenFn)
5455
require.NotNil(t, a.verifyIDPIssuedTokenFn)
56+
require.NotNil(t, a.verifyKubernetesTokenFn)
5557
require.NotNil(t, a.oidcExtractClaimsFn)
5658
require.NotNil(t, a.listServiceAccountsFn)
5759
}
@@ -199,14 +201,10 @@ func TestAuthenticate(t *testing.T) {
199201
},
200202
},
201203
token: testToken,
202-
// We can't parse the token as a JWT, so we assume it could be an opaque
203-
// bearer token for the k8s API server. We expect user info containing the
204-
// raw token to be bound to the context.
205204
assertions: func(ctx context.Context, err error) {
206-
require.NoError(t, err)
207-
u, ok := user.InfoFromContext(ctx)
208-
require.True(t, ok)
209-
require.Equal(t, testToken, u.BearerToken)
205+
require.Equal(t, "invalid token", err.Error())
206+
_, ok := user.InfoFromContext(ctx)
207+
require.False(t, ok)
210208
},
211209
},
212210
"failure verifying Kargo-issued token": {
@@ -354,7 +352,7 @@ func TestAuthenticate(t *testing.T) {
354352
require.Equal(t, testToken, u.BearerToken)
355353
},
356354
},
357-
"unrecognized JWT": {
355+
"unrecognized JWT recognized by Kubernetes": {
358356
procedure: testProcedure,
359357
authInterceptor: &authInterceptor{
360358
parseUnverifiedJWTFn: func(_ string, claims jwt.Claims) (*jwt.Token, []string, error) {
@@ -363,18 +361,44 @@ func TestAuthenticate(t *testing.T) {
363361
rc.Issuer = "unrecognized-issuer"
364362
return nil, nil, nil
365363
},
364+
verifyKubernetesTokenFn: func(context.Context, string) error {
365+
return nil // Token is recognized by Kubernetes
366+
},
366367
},
367368
token: testToken,
368-
// We can't verify this token, so we assume it could be an an identity
369-
// token from the k8s API server's identity provider. We expect user info
370-
// containing the raw token to be bound to the context.
369+
// We can't verify this token, so we check if Kubernetes recognizes it.
370+
// In this case it does, so we expect user info containing the raw token
371+
// to be bound to the context.
371372
assertions: func(ctx context.Context, err error) {
372373
require.NoError(t, err)
373374
u, ok := user.InfoFromContext(ctx)
374375
require.True(t, ok)
375376
require.Equal(t, testToken, u.BearerToken)
376377
},
377378
},
379+
"unrecognized JWT not recognized by Kubernetes": {
380+
procedure: testProcedure,
381+
authInterceptor: &authInterceptor{
382+
parseUnverifiedJWTFn: func(_ string, claims jwt.Claims) (*jwt.Token, []string, error) {
383+
rc, ok := claims.(*jwt.RegisteredClaims)
384+
require.True(t, ok)
385+
rc.Issuer = "unrecognized-issuer"
386+
return nil, nil, nil
387+
},
388+
verifyKubernetesTokenFn: func(context.Context, string) error {
389+
return errors.New("token not recognized")
390+
},
391+
},
392+
token: testToken,
393+
// We can't verify this token and Kubernetes doesn't recognize it either.
394+
// This should result in an authentication error.
395+
assertions: func(ctx context.Context, err error) {
396+
require.Error(t, err)
397+
require.Equal(t, "invalid token", err.Error())
398+
_, ok := user.InfoFromContext(ctx)
399+
require.False(t, ok)
400+
},
401+
},
378402
}
379403
for name, ts := range testSets {
380404
t.Run(name, func(t *testing.T) {
@@ -594,3 +618,46 @@ func TestVerifyKargoIssuedToken(t *testing.T) {
594618
})
595619
}
596620
}
621+
622+
func TestVerifyKubernetesToken(t *testing.T) {
623+
const testToken = "test-bearer-token"
624+
625+
testCases := []struct {
626+
name string
627+
mockK8sAPIHandler http.HandlerFunc
628+
assertions func(t *testing.T, err error)
629+
}{
630+
{
631+
name: "Kubernetes API returns 200",
632+
mockK8sAPIHandler: func(w http.ResponseWriter, r *http.Request) {
633+
// Verify the token was passed correctly
634+
require.Equal(t, "Bearer "+testToken, r.Header.Get("Authorization"))
635+
require.Equal(t, "/api", r.URL.Path)
636+
w.WriteHeader(http.StatusOK)
637+
},
638+
assertions: func(t *testing.T, err error) {
639+
require.NoError(t, err)
640+
},
641+
},
642+
{
643+
name: "Kubernetes API returns non-200",
644+
mockK8sAPIHandler: func(w http.ResponseWriter, _ *http.Request) {
645+
w.WriteHeader(http.StatusUnauthorized)
646+
},
647+
assertions: func(t *testing.T, err error) {
648+
require.ErrorContains(t, err, "unexpected response from Kubernetes API server: 401")
649+
},
650+
},
651+
}
652+
for _, testCase := range testCases {
653+
t.Run(testCase.name, func(t *testing.T) {
654+
srv := httptest.NewServer(testCase.mockK8sAPIHandler)
655+
t.Cleanup(srv.Close)
656+
authenticator := &authInterceptor{
657+
cfg: config.ServerConfig{RestConfig: &rest.Config{Host: srv.URL}},
658+
}
659+
err := authenticator.verifyKubernetesToken(t.Context(), testToken)
660+
testCase.assertions(t, err)
661+
})
662+
}
663+
}

0 commit comments

Comments
 (0)