Skip to content

Commit b5db057

Browse files
committed
feat(sdk): add gateway integration, auth, fakes, and edge client
Add the gateway client (reads CLI config), OIDC authentication with token refresh, edge/tunnel client, and in-memory fake implementations for testing. Part 2 of 4 for issue NVIDIA#2044. Assisted-By: 🤖 Claude Code Signed-off-by: Roland Huß <rhuss@redhat.com>
1 parent 886f65c commit b5db057

70 files changed

Lines changed: 11704 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package edge
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
10+
v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1"
11+
)
12+
13+
// CloudflareAccess returns an AuthProvider that adds Cloudflare Access
14+
// headers to every RPC. It sets:
15+
// - cf-access-jwt-assertion: the edge JWT token
16+
// - cookie: CF_Authorization=<token>
17+
//
18+
// The edgeToken authenticates with the Cloudflare Access edge proxy.
19+
// Returns an error if baseAuth is nil or edgeToken is empty.
20+
func CloudflareAccess(baseAuth v1.AuthProvider, edgeToken string) (v1.AuthProvider, error) {
21+
if edgeToken == "" {
22+
return nil, errors.New("edge token must not be empty")
23+
}
24+
25+
return v1.WithExtraHeaders(baseAuth, map[string]string{
26+
"cf-access-jwt-assertion": edgeToken,
27+
"cookie": fmt.Sprintf("CF_Authorization=%s", edgeToken),
28+
})
29+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package edge
5+
6+
import (
7+
"context"
8+
"testing"
9+
10+
v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestCloudflareAccess_ValidToken(t *testing.T) {
16+
base := v1.StaticToken("my-token")
17+
auth, err := CloudflareAccess(base, "cf-edge-jwt-xxx")
18+
require.NoError(t, err)
19+
20+
md, err := auth.GetRequestMetadata(context.Background())
21+
require.NoError(t, err)
22+
23+
// Base auth header preserved.
24+
assert.Equal(t, "Bearer my-token", md["authorization"])
25+
26+
// Cloudflare-specific headers present.
27+
assert.Equal(t, "cf-edge-jwt-xxx", md["cf-access-jwt-assertion"])
28+
assert.Equal(t, "CF_Authorization=cf-edge-jwt-xxx", md["cookie"])
29+
}
30+
31+
func TestCloudflareAccess_EmptyToken(t *testing.T) {
32+
base := v1.StaticToken("my-token")
33+
_, err := CloudflareAccess(base, "")
34+
require.Error(t, err)
35+
assert.Contains(t, err.Error(), "edge token")
36+
}
37+
38+
func TestCloudflareAccess_NilBase(t *testing.T) {
39+
_, err := CloudflareAccess(nil, "cf-edge-jwt-xxx")
40+
require.Error(t, err)
41+
assert.Contains(t, err.Error(), "base")
42+
}
43+
44+
func TestCloudflareAccess_WithNoAuth(t *testing.T) {
45+
auth, err := CloudflareAccess(v1.NoAuth(), "cf-edge-jwt-xxx")
46+
require.NoError(t, err)
47+
48+
md, err := auth.GetRequestMetadata(context.Background())
49+
require.NoError(t, err)
50+
51+
// NoAuth provides no base metadata; only CF headers should appear.
52+
assert.Equal(t, "cf-edge-jwt-xxx", md["cf-access-jwt-assertion"])
53+
assert.Equal(t, "CF_Authorization=cf-edge-jwt-xxx", md["cookie"])
54+
}
55+
56+
func TestCloudflareAccess_RequireTransportSecurity_Delegates(t *testing.T) {
57+
tests := []struct {
58+
name string
59+
base v1.AuthProvider
60+
expected bool
61+
}{
62+
{
63+
name: "delegates to NoAuth (false)",
64+
base: v1.NoAuth(),
65+
expected: false,
66+
},
67+
{
68+
name: "delegates to StaticToken (true)",
69+
base: v1.StaticToken("tok"),
70+
expected: true,
71+
},
72+
}
73+
for _, tt := range tests {
74+
t.Run(tt.name, func(t *testing.T) {
75+
auth, err := CloudflareAccess(tt.base, "cf-edge-jwt-xxx")
76+
require.NoError(t, err)
77+
assert.Equal(t, tt.expected, auth.RequireTransportSecurity())
78+
})
79+
}
80+
}
81+
82+
func TestCloudflareAccess_TokenNotInError(t *testing.T) {
83+
// Verify the error for empty token does not leak actual token values.
84+
_, err := CloudflareAccess(v1.StaticToken("s3cr3t-val"), "")
85+
require.Error(t, err)
86+
// The error should mention the parameter name, not any token value.
87+
assert.NotContains(t, err.Error(), "s3cr3t-val")
88+
}

sdk/go/openshell/v1/edge/doc.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package edge provides utilities for connecting to OpenShell gateways
5+
// through edge proxies such as Cloudflare Access. It includes convenience
6+
// constructors for common edge auth patterns and a WebSocket tunnel proxy
7+
// for gRPC transport through HTTP/1.1-only proxies.
8+
//
9+
// # Cloudflare Access
10+
//
11+
// CloudflareAccess wraps any AuthProvider with the headers required by
12+
// Cloudflare Access (cf-access-jwt-assertion and CF_Authorization cookie).
13+
// The edge token is typically a service token or application token obtained
14+
// from Cloudflare:
15+
//
16+
// base := v1.StaticToken("my-gateway-token")
17+
// auth, err := edge.CloudflareAccess(base, os.Getenv("CF_ACCESS_TOKEN"))
18+
// if err != nil {
19+
// log.Fatal(err)
20+
// }
21+
// client, err := v1.NewClient(v1.Config{
22+
// Address: "gateway.example.com:443",
23+
// Auth: auth,
24+
// })
25+
// if err != nil {
26+
// log.Fatal(err)
27+
// }
28+
// defer client.Close()
29+
//
30+
// CloudflareAccess composes with any auth provider, including RefreshableToken
31+
// for automatic token refresh:
32+
//
33+
// tokenSource := oauth2Config.TokenSource(ctx, initialToken)
34+
// refreshAuth, err := v1.RefreshableToken(tokenSource)
35+
// if err != nil {
36+
// log.Fatal(err)
37+
// }
38+
// auth, err := edge.CloudflareAccess(refreshAuth, cfToken)
39+
// if err != nil {
40+
// log.Fatal(err)
41+
// }
42+
//
43+
// # WebSocket Tunnel
44+
//
45+
// TunnelProxy bridges gRPC connections over a WebSocket tunnel for edge
46+
// proxies that reject standard HTTP/2 POST requests. The tunnel carries
47+
// its own edge token for proxy authentication, independent of the
48+
// application-level auth provider.
49+
//
50+
// Create a tunnel proxy pointed at the gateway, then dial the proxy's
51+
// local address from the gRPC client:
52+
//
53+
// tunnel, err := edge.NewTunnelProxy(
54+
// "wss://gateway.example.com/ws",
55+
// os.Getenv("CF_ACCESS_TOKEN"),
56+
// )
57+
// if err != nil {
58+
// log.Fatal(err)
59+
// }
60+
// defer tunnel.Close()
61+
//
62+
// auth := v1.StaticToken("my-gateway-token")
63+
// client, err := v1.NewClient(v1.Config{
64+
// Address: tunnel.Addr(),
65+
// Auth: auth,
66+
// TLS: &v1.TLSConfig{Insecure: true}, // local tunnel
67+
// })
68+
// if err != nil {
69+
// log.Fatal(err)
70+
// }
71+
// defer client.Close()
72+
//
73+
// Use functional options to configure TLS, logging, and close timeout:
74+
//
75+
// tunnel, err := edge.NewTunnelProxy(
76+
// "wss://gateway.example.com/ws",
77+
// cfToken,
78+
// edge.WithTunnelTLS(&tls.Config{RootCAs: customCertPool}),
79+
// edge.WithTunnelLogger(myLogger),
80+
// edge.WithCloseTimeout(10*time.Second),
81+
// )
82+
//
83+
// Close drains in-flight connections gracefully. If draining exceeds the
84+
// configured timeout (default 5 seconds), remaining connections are
85+
// force-closed:
86+
//
87+
// err := tunnel.Close() // safe to call multiple times
88+
package edge

0 commit comments

Comments
 (0)