Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
87 changes: 87 additions & 0 deletions e2e/auth/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package auth

import (
"context"
"crypto/rand"
"crypto/rsa"
"fmt"
"net/http"
"time"

"github.com/golang-jwt/jwt/v5"

"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/api/openapi"
)

// withoutAuth returns a RequestEditorFn that removes the Authorization header,
// overriding any token the client would normally inject.
func withoutAuth() openapi.RequestEditorFn {
return func(_ context.Context, req *http.Request) error {
req.Header.Del("Authorization")
return nil
}
}

// withBearerToken returns a RequestEditorFn that replaces the Authorization
// header with the given bearer token, overriding any token the client would
// normally inject.
func withBearerToken(token string) openapi.RequestEditorFn {
return func(_ context.Context, req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+token)
return nil
}
}

// craftJWT creates a self-signed JWT with arbitrary claims, signed with a random RSA key.
// Useful for testing rejection of tokens with invalid signatures, expired tokens, etc.
func craftJWT(claims jwt.MapClaims) (string, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", fmt.Errorf("generating RSA key: %w", err)
}

token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signed, err := token.SignedString(key)
if err != nil {
return "", fmt.Errorf("signing JWT: %w", err)
}
return signed, nil
}

// craftExpiredJWT creates a self-signed JWT that has already expired.
func craftExpiredJWT() (string, error) {
now := time.Now()
return craftJWT(jwt.MapClaims{
"iss": "https://expired-issuer.example.com",
"sub": "system:serviceaccount:test:expired-sa",
"aud": "hyperfleet-api",
"exp": jwt.NewNumericDate(now.Add(-1 * time.Hour)),
"iat": jwt.NewNumericDate(now.Add(-2 * time.Hour)),
})
}

// craftInvalidSignatureJWT creates a structurally valid JWT signed with a random key
// that the API's JWKS endpoint won't recognize.
func craftInvalidSignatureJWT() (string, error) {
now := time.Now()
return craftJWT(jwt.MapClaims{
"iss": "https://kubernetes.default.svc",
"sub": "system:serviceaccount:hyperfleet:fake-sa",
"aud": "hyperfleet-api",
"exp": jwt.NewNumericDate(now.Add(1 * time.Hour)),
"iat": jwt.NewNumericDate(now),
})
}

// craftUnconfiguredIssuerJWT creates a JWT with an issuer URL not in the API's
// configured issuer list. The token is structurally valid but from an unknown issuer.
func craftUnconfiguredIssuerJWT() (string, error) {
now := time.Now()
return craftJWT(jwt.MapClaims{
"iss": "https://unconfigured-issuer.example.com",
"sub": "user@unconfigured.example.com",
"aud": "hyperfleet-api",
"exp": jwt.NewNumericDate(now.Add(1 * time.Hour)),
"iat": jwt.NewNumericDate(now),
})
}
97 changes: 97 additions & 0 deletions e2e/auth/jwt_enforcement.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package auth

import (
"context"
"net/http"

"github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" //nolint:staticcheck // dot import for test readability

"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/helper"
"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/labels"
)

var _ = ginkgo.Describe("[Suite: auth][enforcement] JWT Authentication Enforcement",
ginkgo.Label(labels.Tier0, labels.Auth, labels.Negative),
func() {
var h *helper.Helper

ginkgo.BeforeEach(func(_ context.Context) {
h = helper.New()
})

ginkgo.It("rejects GET request without Authorization header with 401 and AUT-001",
func(ctx context.Context) {
ginkgo.By("sending GET without Authorization header")
resp, err := h.Client.GetClusters(ctx, nil, withoutAuth())
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"unauthenticated GET should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-001"))
})

ginkgo.It("rejects POST request without Authorization header with 401 and AUT-001",
func(ctx context.Context) {
ginkgo.By("sending POST without Authorization header")
resp, err := h.Client.PostClusterWithBody(ctx, "application/json", nil, withoutAuth())
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"unauthenticated POST should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-001"))
})

ginkgo.It("rejects requests with invalid JWT signature with 401 and AUT-002",
func(ctx context.Context) {
ginkgo.By("crafting a JWT signed with an unknown key")
token, err := craftInvalidSignatureJWT()
Expect(err).NotTo(HaveOccurred(), "crafting JWT should succeed")

ginkgo.By("sending GET with invalid-signature bearer token")
resp, err := h.Client.GetClusters(ctx, nil, withBearerToken(token))
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"invalid signature should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-002"))
})

// Note: the error model defines AUT-003 for expired tokens, but the API's JWT middleware
// currently returns AUT-002 (invalid credentials) for all unverifiable tokens.
// This test validates the deployed behavior.
ginkgo.It("rejects requests with expired token with 401",
func(ctx context.Context) {
ginkgo.By("crafting a self-signed expired JWT")
token, err := craftExpiredJWT()
Expect(err).NotTo(HaveOccurred(), "crafting expired JWT should succeed")

ginkgo.By("sending GET with expired bearer token")
resp, err := h.Client.GetClusters(ctx, nil, withBearerToken(token))
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"expired token should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-002"))
})

// Note: the error model defines AUT-004 for malformed tokens, but the API's JWT middleware
// currently returns AUT-002 (invalid credentials) for all unverifiable tokens.
// This test validates the deployed behavior.
ginkgo.It("rejects requests with malformed token with 401",
func(ctx context.Context) {
ginkgo.By("sending GET with a non-JWT string as bearer token")
resp, err := h.Client.GetClusters(ctx, nil, withBearerToken("not-a-jwt"))
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"malformed token should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-002"))
})
},
)
141 changes: 141 additions & 0 deletions e2e/auth/multi_issuer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package auth

import (
"context"
"fmt"
"net/http"

"github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" //nolint:staticcheck // dot import for test readability

"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/api/openapi"
"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/client"
"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/helper"
"github.com/openshift-hyperfleet/hyperfleet-e2e/pkg/labels"
)

var _ = ginkgo.Describe("[Suite: auth][multi-issuer] JWT Identity and Issuer Validation",
ginkgo.Label(labels.Tier0, labels.Auth),
func() {
var h *helper.Helper

ginkgo.BeforeEach(func(_ context.Context) {
h = helper.New()
})

ginkgo.It("populates audit fields on write requests from JWT sub claim",
func(ctx context.Context) {
expected := h.ExpectedIdentity()
if expected == "" {
ginkgo.Skip("identity.expectedIdentity not configured - skipping audit field assertion")
}

ginkgo.By("creating a cluster and verifying created_by")
cluster, err := h.Client.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json"))
Expect(err).NotTo(HaveOccurred(), "cluster creation should succeed")
Expect(cluster.Id).NotTo(BeNil())
clusterID := *cluster.Id

h.DeferClusterCleanup(clusterID)

Expect(cluster.CreatedBy).To(Equal(expected),
"created_by should contain the JWT sub claim identity")

ginkgo.By("patching the cluster and verifying updated_by")
patched, err := h.Client.PatchClusterFromPayload(ctx, clusterID, h.TestDataPath("payloads/clusters/cluster-patch.json"))
Expect(err).NotTo(HaveOccurred(), "cluster PATCH should succeed")
Expect(patched.UpdatedBy).To(Equal(expected),
"updated_by should contain the JWT sub claim identity")

ginkgo.By("waiting for cluster to reconcile before delete")
Eventually(h.PollCluster(ctx, clusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval).
Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue))

ginkgo.By("deleting the cluster and verifying deleted_by")
deleted, err := h.Client.DeleteCluster(ctx, clusterID)
Expect(err).NotTo(HaveOccurred(), "cluster DELETE should succeed")
Expect(deleted.DeletedBy).NotTo(BeNil(), "deleted_by should be set on soft-delete")
Expect(*deleted.DeletedBy).To(Equal(expected),
"deleted_by should contain the JWT sub claim identity")
})

ginkgo.It("rejects tokens from unconfigured issuers with 401 and AUT-002",
func(ctx context.Context) {
ginkgo.By("crafting a JWT from an unconfigured issuer")
token, err := craftUnconfiguredIssuerJWT()
Expect(err).NotTo(HaveOccurred(), "crafting unconfigured-issuer JWT should succeed")

ginkgo.By("sending request with unconfigured issuer token")
resp, err := h.Client.GetClusters(ctx, nil, withBearerToken(token))
Expect(err).NotTo(HaveOccurred(), "HTTP request should succeed at transport level")
defer func() { _ = resp.Body.Close() }()

Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized),
"unconfigured issuer should return 401")
Expect(resp).To(helper.HaveRFC9457Error("HYPERFLEET-AUT-002"))
})

ginkgo.It("accepts tokens from different service accounts within the configured issuer",
func(ctx context.Context) {
if !h.Cfg.Identity.TokenRequest.IsEnabled() {
ginkgo.Skip("TokenRequest not configured - cannot acquire token for a different SA")
}

// Use a different SA name in the same namespace to prove the API accepts
// tokens from any SA under the configured K8s issuer, not just the E2E SA.
altSAName := h.Cfg.Identity.TokenRequest.ServiceAccountName + "-alt"
ns := h.Cfg.Identity.TokenRequest.Namespace
audience := h.Cfg.Identity.TokenRequest.Audience
expiration := h.Cfg.Identity.TokenRequest.ExpirationSeconds

ginkgo.By("acquiring a token for a different service account")
altToken, err := h.K8sClient.CreateToken(ctx, ns, altSAName, audience, expiration)
if err != nil {
ginkgo.Skip(fmt.Sprintf("cannot acquire token for alt SA %s/%s (SA may not exist): %v", ns, altSAName, err))
}

ginkgo.By("creating a cluster with the alternative SA token")
altClient, err := client.NewHyperFleetClient(h.Cfg.API.URL, nil, openapi.WithRequestEditorFn(withBearerToken(altToken)))
Expect(err).NotTo(HaveOccurred(), "creating alt client should succeed")

cluster, err := altClient.CreateClusterFromPayload(ctx, h.TestDataPath("payloads/clusters/cluster-request.json"))
Expect(err).NotTo(HaveOccurred(), "cluster creation with alt SA token should succeed")
Expect(cluster.Id).NotTo(BeNil())
clusterID := *cluster.Id

h.DeferClusterCleanup(clusterID)

ginkgo.By("verifying audit field shows the alternative SA identity")
expectedAltIdentity := fmt.Sprintf("system:serviceaccount:%s:%s", ns, altSAName)
Expect(cluster.CreatedBy).To(Equal(expectedAltIdentity),
"created_by should reflect the alternative SA identity, not the primary E2E SA")
})

ginkgo.It("confirms adapter identity is populated in cluster status reports",
func(ctx context.Context) {
ginkgo.By("creating a cluster and waiting for Reconciled")
clusterID, err := h.GetTestCluster(ctx, h.TestDataPath("payloads/clusters/cluster-request.json"))
Expect(err).NotTo(HaveOccurred(), "cluster creation should succeed")

h.DeferClusterCleanup(clusterID)

Eventually(h.PollCluster(ctx, clusterID), h.Cfg.Timeouts.Cluster.Reconciled, h.Cfg.Polling.Interval).
Should(helper.HaveResourceCondition(client.ConditionTypeReconciled, openapi.ResourceConditionStatusTrue))

ginkgo.By("verifying adapter status reports have non-empty identity")
statuses, err := h.Client.GetClusterStatuses(ctx, clusterID)
Expect(err).NotTo(HaveOccurred(), "getting cluster statuses should succeed")
Expect(statuses.Items).NotTo(BeEmpty(), "at least one adapter should have reported")

// Adapter statuses are PUT by the adapter using its own SA token.
// We can't predict the exact adapter SA identity, but we can verify
// the status was created (CreatedTime is set), which proves the adapter
// successfully authenticated to the API with JWT.
for _, status := range statuses.Items {
Expect(status.CreatedTime).NotTo(BeZero(),
"adapter %s status should have created_time set (proving JWT-authenticated PUT succeeded)",
status.Adapter)
}
})
},
)
Loading