Skip to content

Commit 3ac63f4

Browse files
mjudeikis-botOpenClaw Botmjudeikis
authored
test: OIDC user cannot access another user's edge (#79) (#96)
Add e2e regression test OIDCCrossUserEdgeIsolation verifying that cross-user edge access is rejected with 403/401. Regression for #63/#75. Changes: - pkg/cli/cmd/dev/plugin/create.go: seed a second Dex static user (user2@test.kedge.local) in the e2e cluster so cross-user OIDC tests have two distinct identities available. - test/e2e/framework/dex.go: add DexTestUser2Email/Password constants, User2Email/User2Password fields to DexEnv, populate in DefaultDexEnv. - test/e2e/cases/auth.go: new file with ProxyInvalidToken (token rejection helpers) and OIDCCrossUserEdgeIsolation — User A creates an edge, User B (different OIDC identity) tries to access it, expects 401/403. - test/e2e/suites/oidc/oidc_test.go: register TestOIDCCrossUserEdgeIsolation. Closes #79 Co-authored-by: OpenClaw Bot <bot@openclaw.ai> Co-authored-by: Mangirdas Judeikis <mangirdas@judeikis.lt>
1 parent b9e2ca2 commit 3ac63f4

4 files changed

Lines changed: 325 additions & 13 deletions

File tree

pkg/cli/cmd/dev/plugin/create.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ const (
9797
devDexChartVersion = "0.24.0"
9898
devDexReleaseName = "dex"
9999
devDexNodePort = 31556
100-
// bcrypt of "Password1!" for the dev Dex static user
100+
// bcrypt of "Password1!" for the dev Dex static users (same password, different identities)
101101
devDexUserHash = "$2a$10$ntVcHD0gEYObjVin2ti7XuMILVz0rTQl//HVPc3cR8z7AAVbQGrkO"
102102
)
103103

@@ -573,12 +573,21 @@ func (o *DevOptions) deployDex(ctx context.Context, restConfig *rest.Config, kub
573573
"redirectURIs": []string{redirectURI},
574574
}},
575575
"enablePasswordDB": true,
576-
"staticPasswords": []map[string]any{{
577-
"email": "admin@test.kedge.local",
578-
"hash": devDexUserHash,
579-
"username": "admin",
580-
"userID": "test-user-id-01",
581-
}},
576+
"staticPasswords": []map[string]any{
577+
{
578+
"email": "admin@test.kedge.local",
579+
"hash": devDexUserHash,
580+
"username": "admin",
581+
"userID": "test-user-id-01",
582+
},
583+
{
584+
// Second user for cross-user isolation e2e tests (issue #79).
585+
"email": "user2@test.kedge.local",
586+
"hash": devDexUserHash, // same password "Password1!" — different identity
587+
"username": "user2",
588+
"userID": "test-user-id-02",
589+
},
590+
},
582591
},
583592
}
584593

test/e2e/cases/auth.go

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
/*
2+
Copyright 2026 The Faros Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cases
18+
19+
import (
20+
"context"
21+
"crypto/tls"
22+
"encoding/base64"
23+
"net/http"
24+
"os"
25+
"path/filepath"
26+
"testing"
27+
"time"
28+
29+
"sigs.k8s.io/e2e-framework/pkg/envconf"
30+
"sigs.k8s.io/e2e-framework/pkg/features"
31+
32+
cliauth "github.com/faroshq/faros-kedge/pkg/cli/auth"
33+
"github.com/faroshq/faros-kedge/test/e2e/framework"
34+
)
35+
36+
// proxyAuthClient is a shared HTTP client that skips TLS verification for the
37+
// hub's self-signed dev certificate. It is intentionally not reusing
38+
// framework.insecureHTTPClient (unexported) so auth test cases remain
39+
// self-contained.
40+
var proxyAuthClient = &http.Client{
41+
Transport: &http.Transport{
42+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test only
43+
},
44+
}
45+
46+
// proxyEdgesURL returns a canonical edges-proxy URL that exercises the token
47+
// validation path. The edge name ("nonexistent") need not exist — auth is
48+
// checked before the edge lookup.
49+
func proxyEdgesURL(hubURL string) string {
50+
return hubURL + "/services/edges-proxy/clusters/test/apis/kedge.faros.sh/v1alpha1/edges/nonexistent/k8s"
51+
}
52+
53+
// doProxyRequest sends a GET to the edges-proxy with the given Authorization
54+
// header value and returns the HTTP status code.
55+
func doProxyRequest(ctx context.Context, hubURL, authHeader string) (int, error) {
56+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, proxyEdgesURL(hubURL), nil)
57+
if err != nil {
58+
return 0, err
59+
}
60+
req.Header.Set("Authorization", authHeader)
61+
62+
resp, err := proxyAuthClient.Do(req)
63+
if err != nil {
64+
return 0, err
65+
}
66+
defer resp.Body.Close() //nolint:errcheck
67+
return resp.StatusCode, nil
68+
}
69+
70+
// isAuthError returns true when the status code is a recognised auth-rejection
71+
// code (401 Unauthorized or 403 Forbidden).
72+
func isAuthError(code int) bool {
73+
return code == http.StatusUnauthorized || code == http.StatusForbidden
74+
}
75+
76+
// fakeBearerJWT returns a syntactically valid but cryptographically invalid JWT
77+
// using base64url-encoded placeholder segments.
78+
func fakeBearerJWT() string {
79+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
80+
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"fake-user","iss":"https://fake.issuer.invalid"}`))
81+
sig := base64.RawURLEncoding.EncodeToString([]byte("invalidsignature"))
82+
return "Bearer " + header + "." + payload + "." + sig
83+
}
84+
85+
// ProxyInvalidToken verifies that the edges-proxy handler rejects requests
86+
// carrying invalid Bearer tokens with HTTP 401 or 403.
87+
//
88+
// Two sub-cases are exercised:
89+
// 1. A completely opaque garbage token ("Bearer <random-string>").
90+
// 2. A syntactically well-formed but cryptographically invalid JWT.
91+
func ProxyInvalidToken() features.Feature {
92+
return features.New("Auth/ProxyInvalidToken").
93+
Assess("garbage_token_returns_401_or_403", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
94+
clusterEnv := framework.ClusterEnvFrom(ctx)
95+
if clusterEnv == nil {
96+
t.Fatal("cluster environment not found in context")
97+
}
98+
99+
code, err := doProxyRequest(ctx, clusterEnv.HubURL, "Bearer this-is-a-garbage-token-xyz")
100+
if err != nil {
101+
t.Fatalf("HTTP request failed: %v", err)
102+
}
103+
if !isAuthError(code) {
104+
t.Fatalf("expected 401 or 403 for garbage Bearer token, got %d", code)
105+
}
106+
t.Logf("garbage token correctly rejected with %d", code)
107+
return ctx
108+
}).
109+
Assess("well_formed_invalid_jwt_returns_401_or_403", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
110+
clusterEnv := framework.ClusterEnvFrom(ctx)
111+
if clusterEnv == nil {
112+
t.Fatal("cluster environment not found in context")
113+
}
114+
115+
code, err := doProxyRequest(ctx, clusterEnv.HubURL, fakeBearerJWT())
116+
if err != nil {
117+
t.Fatalf("HTTP request failed: %v", err)
118+
}
119+
if !isAuthError(code) {
120+
t.Fatalf("expected 401 or 403 for well-formed invalid JWT, got %d", code)
121+
}
122+
t.Logf("well-formed invalid JWT correctly rejected with %d", code)
123+
return ctx
124+
}).
125+
Feature()
126+
}
127+
128+
// oidcIsolationData carries state between Setup → Assess → Teardown for the
129+
// OIDCCrossUserEdgeIsolation test.
130+
type oidcIsolationData struct {
131+
// edgeProxyURL is the full URL for accessing User A's edge via the hub proxy.
132+
edgeProxyURL string
133+
// userBToken is the OIDC ID token obtained by User B.
134+
userBToken string
135+
// userAKubeconfig is the path to User A's kubeconfig (for teardown cleanup).
136+
userAKubeconfig string
137+
// edgeName is the name of the edge created by User A.
138+
edgeName string
139+
}
140+
141+
type oidcIsolationKey struct{}
142+
143+
// OIDCCrossUserEdgeIsolation verifies that OIDC User B cannot access an edge
144+
// registered by OIDC User A via the hub proxy. Regression test for the OIDC
145+
// auth bypass fixed in #75 (see also issue #63, #79).
146+
//
147+
// Flow:
148+
// 1. User A performs a headless OIDC login → obtains kubeconfig + ID token.
149+
// 2. User A creates an Edge resource in their kcp workspace.
150+
// 3. User B performs a headless OIDC login → obtains an ID token (different identity).
151+
// 4. User B sends a GET request to User A's edge proxy URL using their own token.
152+
// 5. Assert the hub returns 401 or 403 (never 200/500).
153+
//
154+
// This test requires a Dex setup with at least two static users
155+
// (DexTestUserEmail and DexTestUser2Email). It is skipped when the second user
156+
// is not configured or when Dex is not available (non-OIDC suite).
157+
func OIDCCrossUserEdgeIsolation() features.Feature {
158+
const edgeName = "e2e-isolation-edge"
159+
160+
return features.New("Auth/OIDCCrossUserEdgeIsolation").
161+
Setup(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
162+
clusterEnv := framework.ClusterEnvFrom(ctx)
163+
dexEnv := framework.DexEnvFrom(ctx)
164+
if clusterEnv == nil || dexEnv == nil {
165+
t.Skip("requires OIDC suite (Dex env not found in context)")
166+
}
167+
if dexEnv.User2Email == "" {
168+
t.Skip("second Dex user not configured; add DexTestUser2Email to the framework")
169+
}
170+
171+
// ── User A: full OIDC login ─────────────────────────────────────
172+
loginCtxA, cancelA := context.WithTimeout(ctx, 90*time.Second)
173+
defer cancelA()
174+
175+
resultA, err := framework.HeadlessOIDCLogin(loginCtxA, clusterEnv.HubURL, dexEnv.UserEmail, dexEnv.UserPassword)
176+
if err != nil {
177+
t.Fatalf("User A OIDC login failed: %v", err)
178+
}
179+
if len(resultA.Kubeconfig) == 0 {
180+
t.Fatal("User A login returned empty kubeconfig")
181+
}
182+
183+
// Write User A's kubeconfig to a temp file.
184+
kcFileA := filepath.Join(t.TempDir(), "user-a.kubeconfig")
185+
if err := os.WriteFile(kcFileA, resultA.Kubeconfig, 0600); err != nil {
186+
t.Fatalf("writing User A kubeconfig: %v", err)
187+
}
188+
189+
// Cache User A's token so the exec-credential plugin can refresh it.
190+
if resultA.IDToken != "" {
191+
tokenCache := &cliauth.TokenCache{
192+
IDToken: resultA.IDToken,
193+
RefreshToken: resultA.RefreshToken,
194+
ExpiresAt: resultA.ExpiresAt,
195+
IssuerURL: resultA.IssuerURL,
196+
ClientID: resultA.ClientID,
197+
}
198+
if err := cliauth.SaveTokenCache(tokenCache); err != nil {
199+
t.Fatalf("caching User A OIDC token: %v", err)
200+
}
201+
}
202+
203+
// ── User A: create an Edge resource ────────────────────────────
204+
clientA := framework.NewKedgeClient(framework.RepoRoot(), kcFileA, clusterEnv.HubURL)
205+
if err := clientA.EdgeCreate(ctx, edgeName, "kubernetes", "env=e2e-isolation"); err != nil {
206+
t.Fatalf("User A creating edge %q: %v", edgeName, err)
207+
}
208+
t.Logf("User A created edge %q", edgeName)
209+
210+
// Derive the kcp workspace cluster name from User A's kubeconfig server URL.
211+
// This is the same cluster name embedded in the hub proxy path.
212+
clusterName := framework.ClusterNameFromKubeconfig(kcFileA)
213+
if clusterName == "" {
214+
t.Fatal("could not extract cluster name from User A's kubeconfig")
215+
}
216+
t.Logf("User A kcp cluster name: %s", clusterName)
217+
218+
// Construct the hub proxy URL for User A's edge.
219+
// Auth is enforced before any edge lookup, so the edge doesn't need to
220+
// be connected for the 403 check to be meaningful.
221+
edgeProxyURL := clusterEnv.HubURL +
222+
"/services/edges-proxy/clusters/" + clusterName +
223+
"/apis/kedge.faros.sh/v1alpha1/edges/" + edgeName + "/k8s"
224+
t.Logf("User A edge proxy URL: %s", edgeProxyURL)
225+
226+
// ── User B: full OIDC login ─────────────────────────────────────
227+
loginCtxB, cancelB := context.WithTimeout(ctx, 90*time.Second)
228+
defer cancelB()
229+
230+
resultB, err := framework.HeadlessOIDCLogin(loginCtxB, clusterEnv.HubURL, dexEnv.User2Email, dexEnv.User2Password)
231+
if err != nil {
232+
t.Fatalf("User B OIDC login failed: %v", err)
233+
}
234+
if resultB.IDToken == "" {
235+
t.Fatal("User B login returned empty ID token")
236+
}
237+
t.Logf("User B (email=%s) login succeeded; token length=%d", dexEnv.User2Email, len(resultB.IDToken))
238+
239+
// Store everything needed by Assess and Teardown.
240+
return context.WithValue(ctx, oidcIsolationKey{}, &oidcIsolationData{
241+
edgeProxyURL: edgeProxyURL,
242+
userBToken: resultB.IDToken,
243+
userAKubeconfig: kcFileA,
244+
edgeName: edgeName,
245+
})
246+
}).
247+
Assess("user_b_cannot_access_user_a_edge", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
248+
data, ok := ctx.Value(oidcIsolationKey{}).(*oidcIsolationData)
249+
if !ok {
250+
t.Skip("isolation data not found (setup may have been skipped)")
251+
}
252+
253+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, data.edgeProxyURL, nil)
254+
if err != nil {
255+
t.Fatalf("building cross-user proxy request: %v", err)
256+
}
257+
req.Header.Set("Authorization", "Bearer "+data.userBToken)
258+
259+
resp, err := proxyAuthClient.Do(req)
260+
if err != nil {
261+
t.Fatalf("cross-user proxy request failed: %v", err)
262+
}
263+
defer resp.Body.Close() //nolint:errcheck
264+
265+
if !isAuthError(resp.StatusCode) {
266+
t.Fatalf("expected 401 or 403 for cross-user edge access, got %d — "+
267+
"possible OIDC auth bypass regression (see #63/#75/#79)", resp.StatusCode)
268+
}
269+
t.Logf("cross-user edge access correctly rejected with %d (regression for #63/#75)", resp.StatusCode)
270+
return ctx
271+
}).
272+
Teardown(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context {
273+
data, ok := ctx.Value(oidcIsolationKey{}).(*oidcIsolationData)
274+
if !ok {
275+
return ctx // setup was skipped, nothing to clean up
276+
}
277+
clientA := framework.NewKedgeClient(framework.RepoRoot(), data.userAKubeconfig, "")
278+
if err := clientA.EdgeDelete(ctx, data.edgeName); err != nil {
279+
t.Logf("warning: teardown edge delete failed (best-effort): %v", err)
280+
}
281+
return ctx
282+
}).
283+
Feature()
284+
}

test/e2e/framework/dex.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,29 @@ const (
4242
DexClientSecret = "kedge-test-secret"
4343

4444
// DexTestUserEmail / DexTestUserPassword are the static-password credentials
45-
// seeded in Dex for e2e OIDC tests.
45+
// seeded in Dex for e2e OIDC tests (primary user / User A).
4646
DexTestUserEmail = "admin@test.kedge.local"
4747
DexTestUserPassword = "Password1!"
48+
49+
// DexTestUser2Email / DexTestUser2Password are the credentials for the second
50+
// Dex static-password user, used in cross-user isolation tests (issue #79).
51+
DexTestUser2Email = "user2@test.kedge.local"
52+
DexTestUser2Password = "Password1!"
4853
)
4954

5055
// DexEnv holds runtime OIDC provider info stored in the test context.
5156
type DexEnv struct {
5257
IssuerURL string
5358
ClientID string
5459
ClientSecret string
60+
// UserEmail / UserPassword are credentials for the primary test user (User A).
5561
UserEmail string
5662
UserPassword string
63+
// User2Email / User2Password are credentials for the secondary test user (User B).
64+
// Used in cross-user isolation tests to verify that User B cannot access
65+
// resources owned by User A.
66+
User2Email string
67+
User2Password string
5768
}
5869

5970
// dexEnvKey is the context key for DexEnv.
@@ -73,11 +84,13 @@ func DexEnvFrom(ctx context.Context) *DexEnv {
7384
// DefaultDexEnv returns the DexEnv used in the e2e OIDC suite.
7485
func DefaultDexEnv() *DexEnv {
7586
return &DexEnv{
76-
IssuerURL: DexIssuerURL,
77-
ClientID: DexClientID,
78-
ClientSecret: DexClientSecret,
79-
UserEmail: DexTestUserEmail,
80-
UserPassword: DexTestUserPassword,
87+
IssuerURL: DexIssuerURL,
88+
ClientID: DexClientID,
89+
ClientSecret: DexClientSecret,
90+
UserEmail: DexTestUserEmail,
91+
UserPassword: DexTestUserPassword,
92+
User2Email: DexTestUser2Email,
93+
User2Password: DexTestUser2Password,
8194
}
8295
}
8396

test/e2e/suites/oidc/oidc_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ func TestEdgeListAccuracyUnderChurn(t *testing.T) {
181181
testenv.Test(t, cases.EdgeListAccuracyUnderChurn())
182182
}
183183

184+
// TestOIDCCrossUserEdgeIsolation verifies that OIDC User B cannot access an
185+
// edge registered by OIDC User A via the hub proxy. Regression for #63/#75.
186+
func TestOIDCCrossUserEdgeIsolation(t *testing.T) {
187+
testenv.Test(t, cases.OIDCCrossUserEdgeIsolation())
188+
}
189+
184190
// TestOIDCTokenIssuerMatchesDiscovery verifies that the hub's OIDC issuer URL
185191
// matches what Dex advertises in its discovery document.
186192
func TestOIDCTokenIssuerMatchesDiscovery(t *testing.T) {

0 commit comments

Comments
 (0)