-
Notifications
You must be signed in to change notification settings - Fork 17
HYPERFLEET-1303 - feat: add JWT authentication E2E test suite #151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kuudori
wants to merge
2
commits into
openshift-hyperfleet:main
Choose a base branch
from
kuudori:feat/HYPERFLEET-1303-jwt-e2e-validation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| package auth | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/rand" | ||
| "crypto/rsa" | ||
| "fmt" | ||
| "net/http" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/golang-jwt/jwt/v5" | ||
| ) | ||
|
|
||
| const apiPrefix = "/api/hyperfleet/v1/" | ||
|
|
||
| // rawRequest makes an HTTP request to the clusters API endpoint. | ||
| // If token is non-empty, it's sent as a Bearer token; otherwise unauthenticated. | ||
| func rawRequest(ctx context.Context, apiURL, method, token string) (*http.Response, error) { | ||
| fullURL := strings.TrimRight(apiURL, "/") + apiPrefix + "clusters" | ||
| req, err := http.NewRequestWithContext(ctx, method, fullURL, nil) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create request: %w", err) | ||
| } | ||
| req.Header.Set("Accept", "application/json") | ||
| if token != "" { | ||
| req.Header.Set("Authorization", "Bearer "+token) | ||
| } | ||
| return (&http.Client{Timeout: 30 * time.Second}).Do(req) | ||
| } | ||
|
|
||
| // 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. | ||
| // Random-key signed, so it can't isolate exp-checking from signature rejection (see AUT-002 note in jwt_enforcement.go). | ||
| 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. | ||
| // Random-key signed, so it can't isolate iss-checking from signature rejection either. | ||
| 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), | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 := rawRequest(ctx, h.Cfg.API.URL, http.MethodGet, "") | ||
| 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 := rawRequest(ctx, h.Cfg.API.URL, http.MethodPost, "") | ||
| 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 := rawRequest(ctx, h.Cfg.API.URL, http.MethodGet, 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 := rawRequest(ctx, h.Cfg.API.URL, http.MethodGet, 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 := rawRequest(ctx, h.Cfg.API.URL, http.MethodGet, "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")) | ||
| }) | ||
| }, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| 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/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(HaveValue(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(HaveValue(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, client.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).To(HaveValue(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 := rawRequest(ctx, h.Cfg.API.URL, http.MethodGet, 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("provisioning the alternative service account") | ||
| Expect(h.EnsureServiceAccount(ctx, ns, altSAName)).To(Succeed(), | ||
| "provisioning alt SA should succeed (requires RBAC to create service accounts in %s)", ns) | ||
| ginkgo.DeferCleanup(func(cleanupCtx context.Context) { | ||
| _ = h.DeleteServiceAccount(cleanupCtx, ns, altSAName) | ||
| }) | ||
|
|
||
| ginkgo.By("acquiring a token for the alternative service account") | ||
| altToken, err := h.K8sClient.CreateToken(ctx, ns, altSAName, audience, expiration) | ||
| Expect(err).NotTo(HaveOccurred(), "acquiring a token for the alt SA should succeed") | ||
|
|
||
| ginkgo.By("creating a cluster with the alternative SA token") | ||
| altClient, err := client.NewHyperFleetClient(h.Cfg.API.URL, nil, client.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(HaveValue(Equal(expectedAltIdentity)), | ||
| "created_by should reflect the alternative SA identity, not the primary E2E SA") | ||
| }) | ||
|
|
||
| ginkgo.It("confirms adapters authenticate with JWT when reporting cluster status", | ||
| 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, client.ResourceConditionStatusTrue)) | ||
|
|
||
| ginkgo.By("verifying adapter status reports were recorded") | ||
| 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") | ||
|
|
||
| // AdapterStatus has no audit-identity field, so this only proves the | ||
| // JWT-authenticated PUT was accepted, not which identity it used. | ||
| 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) | ||
| } | ||
| }) | ||
| }, | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.