Skip to content

Commit 565c6c1

Browse files
authored
Merge pull request #706 from Iam-Karan-Suresh/cli/web-auth-secret
cmd: add command to generate Flux Web-auth secret
2 parents 7d52592 + 21cbf2d commit 565c6c1

3 files changed

Lines changed: 339 additions & 0 deletions

File tree

cmd/cli/create_secret_webauth.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright 2026 Stefan Prodan.
2+
// SPDX-License-Identifier: AGPL-3.0
3+
4+
package main
5+
6+
import (
7+
"context"
8+
"crypto/rand"
9+
"encoding/base64"
10+
"fmt"
11+
12+
"github.com/fluxcd/pkg/runtime/secrets"
13+
"github.com/spf13/cobra"
14+
corev1 "k8s.io/api/core/v1"
15+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16+
)
17+
18+
var createSecretWebAuthCmd = &cobra.Command{
19+
Use: "web-auth [name]",
20+
Short: "Create a Kubernetes Secret containing web UI authentication credentials",
21+
Example: ` # Create or update a secret with OAuth2 client credentials
22+
flux-operator create secret web-auth flux-web-auth \
23+
--namespace=flux-system \
24+
--client-id=flux-web \
25+
--client-secret=$client_secret
26+
27+
# Create a secret with random client secret
28+
flux-operator create secret web-auth flux-web-client \
29+
--client-id=flux-web \
30+
--client-secret-rnd
31+
32+
# Create a secret with client secret from stdin
33+
echo $client_secret | flux-operator create secret web-auth flux-web-client \
34+
--client-id=flux-web \
35+
--client-secret-stdin
36+
37+
# Generate a web-auth secret and export it to YAML file
38+
flux-operator create secret web-auth flux-web-client \
39+
--client-id=flux-web \
40+
--client-secret-rnd \
41+
--export > flux-web-auth.yaml
42+
`,
43+
Args: cobra.ExactArgs(1),
44+
RunE: CreateSecretWebAuthCmdRun,
45+
}
46+
47+
type createSecretWebAuthFlags struct {
48+
clientID string
49+
clientSecret string
50+
clientSecretStdin bool
51+
clientSecretRnd bool
52+
53+
annotations []string
54+
labels []string
55+
immutable bool
56+
export bool
57+
}
58+
59+
var createSecretWebAuthArgs createSecretWebAuthFlags
60+
61+
func init() {
62+
createSecretWebAuthCmd.Flags().StringVar(&createSecretWebAuthArgs.clientID, "client-id", "", "set the client ID for OAuth2 authentication (required)")
63+
createSecretWebAuthCmd.Flags().StringVar(&createSecretWebAuthArgs.clientSecret, "client-secret", "", "set the client secret for OAuth2 authentication (required)")
64+
createSecretWebAuthCmd.Flags().BoolVar(&createSecretWebAuthArgs.clientSecretStdin, "client-secret-stdin", false, "read the client secret from standard input")
65+
createSecretWebAuthCmd.Flags().BoolVar(&createSecretWebAuthArgs.clientSecretRnd, "client-secret-rnd", false, "generate a random client secret")
66+
createSecretWebAuthCmd.Flags().StringSliceVar(&createSecretWebAuthArgs.annotations, "annotation", nil, "set annotations on the resource (can specify multiple annotations with commas: annotation1=value1,annotation2=value2)")
67+
createSecretWebAuthCmd.Flags().StringSliceVar(&createSecretWebAuthArgs.labels, "label", nil, "set labels on the resource (can specify multiple labels with commas: label1=value1,label2=value2)")
68+
createSecretWebAuthCmd.Flags().BoolVar(&createSecretWebAuthArgs.immutable, "immutable", false, "set the immutable flag on the Secret")
69+
createSecretWebAuthCmd.Flags().BoolVar(&createSecretWebAuthArgs.export, "export", false, "export resource in YAML format to stdout")
70+
createSecretCmd.AddCommand(createSecretWebAuthCmd)
71+
}
72+
73+
func CreateSecretWebAuthCmdRun(cmd *cobra.Command, args []string) error {
74+
if len(args) != 1 {
75+
return fmt.Errorf("a single name must be specified")
76+
}
77+
name := args[0]
78+
79+
if createSecretWebAuthArgs.clientID == "" {
80+
return fmt.Errorf("--client-id is required")
81+
}
82+
clientSecret := createSecretWebAuthArgs.clientSecret
83+
84+
secretSources := 0
85+
if createSecretWebAuthArgs.clientSecret != "" {
86+
secretSources++
87+
}
88+
if createSecretWebAuthArgs.clientSecretStdin {
89+
secretSources++
90+
}
91+
if createSecretWebAuthArgs.clientSecretRnd {
92+
secretSources++
93+
}
94+
if secretSources == 0 {
95+
return fmt.Errorf("one of --client-secret, --client-secret-stdin, or --client-secret-rnd must be specified")
96+
}
97+
if secretSources > 1 {
98+
return fmt.Errorf("only one of --client-secret, --client-secret-stdin, or --client-secret-rnd can be specified")
99+
}
100+
101+
if createSecretWebAuthArgs.clientSecretStdin {
102+
var input string
103+
_, err := fmt.Scan(&input)
104+
if err != nil {
105+
return fmt.Errorf("unable to read client secret from stdin: %w", err)
106+
}
107+
clientSecret = input
108+
}
109+
if createSecretWebAuthArgs.clientSecretRnd {
110+
randomBytes := make([]byte, 32)
111+
if _, err := rand.Read(randomBytes); err != nil {
112+
return fmt.Errorf("unable to generate random client secret: %w", err)
113+
}
114+
clientSecret = base64.RawURLEncoding.EncodeToString(randomBytes)
115+
}
116+
117+
secret := &corev1.Secret{
118+
ObjectMeta: metav1.ObjectMeta{
119+
Name: name,
120+
Namespace: *kubeconfigArgs.Namespace,
121+
},
122+
Type: corev1.SecretTypeOpaque,
123+
StringData: map[string]string{
124+
"client-id": createSecretWebAuthArgs.clientID,
125+
"client-secret": clientSecret,
126+
},
127+
}
128+
secret.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret"))
129+
130+
if err := setSecretMetadata(
131+
secret,
132+
createSecretWebAuthArgs.annotations,
133+
createSecretWebAuthArgs.labels,
134+
); err != nil {
135+
return fmt.Errorf("unable to set metadata on secret: %w", err)
136+
}
137+
138+
if createSecretWebAuthArgs.export {
139+
return printSecret(secret)
140+
}
141+
142+
ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout)
143+
defer cancel()
144+
145+
kubeClient, err := newKubeClient()
146+
if err != nil {
147+
return fmt.Errorf("unable to create kube client: %w", err)
148+
}
149+
err = secrets.Apply(
150+
ctx,
151+
kubeClient,
152+
secret,
153+
secrets.WithForce(),
154+
secrets.WithImmutable(createSecretWebAuthArgs.immutable),
155+
)
156+
if err != nil {
157+
return err
158+
}
159+
160+
rootCmd.Println(`✔`, fmt.Sprintf("Secret %s/%s applied successfully", secret.GetNamespace(), secret.GetName()))
161+
return nil
162+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright 2026 Stefan Prodan.
2+
// SPDX-License-Identifier: AGPL-3.0
3+
4+
package main
5+
6+
import (
7+
"context"
8+
"testing"
9+
10+
. "github.com/onsi/gomega"
11+
corev1 "k8s.io/api/core/v1"
12+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
13+
"k8s.io/apimachinery/pkg/types"
14+
"sigs.k8s.io/yaml"
15+
)
16+
17+
func TestCreateSecretWebAuthCmd(t *testing.T) {
18+
gt := NewWithT(t)
19+
ns, err := testEnv.CreateNamespace(context.Background(), "test-web-auth")
20+
gt.Expect(err).ToNot(HaveOccurred())
21+
22+
tests := []struct {
23+
name string
24+
args []string
25+
expectError bool
26+
expectExport bool
27+
}{
28+
{
29+
name: "create web-auth secret with client-secret",
30+
args: []string{"create", "secret", "web-auth", "test-secret", "--client-id=test-client", "--client-secret=test-secret-value"},
31+
expectError: false,
32+
},
33+
{
34+
name: "create web-auth secret with random client-secret",
35+
args: []string{"create", "secret", "web-auth", "test-secret-rnd", "--client-id=test-client", "--client-secret-rnd"},
36+
expectError: false,
37+
},
38+
{
39+
name: "create web-auth secret with export",
40+
args: []string{"create", "secret", "web-auth", "test-secret", "--client-id=test-client", "--client-secret=test-secret-value", "--export"},
41+
expectError: false,
42+
expectExport: true,
43+
},
44+
{
45+
name: "create web-auth secret with random and export",
46+
args: []string{"create", "secret", "web-auth", "test-secret", "--client-id=test-client", "--client-secret-rnd", "--export"},
47+
expectError: false,
48+
expectExport: true,
49+
},
50+
{
51+
name: "create web-auth secret with annotations and labels",
52+
// FIX 1: flag is now registered as "--label" (singular) in init(),
53+
// consistent with "--annotation". Test arg updated to match.
54+
args: []string{"create", "secret", "web-auth", "test-secret", "--client-id=test-client", "--client-secret=test-secret-value", "--annotation=test.io/annotation=value", "--label=test.io/label=value"},
55+
expectError: false,
56+
},
57+
{
58+
name: "create immutable web-auth secret",
59+
args: []string{"create", "secret", "web-auth", "test-secret", "--client-id=test-client", "--client-secret=test-secret-value", "--immutable"},
60+
expectError: false,
61+
},
62+
{
63+
name: "missing client-id",
64+
// FIX 2: removed expectExport: true — never evaluated when expectError is true,
65+
// misleading to readers and can mask real intent of the test case.
66+
args: []string{"create", "secret", "web-auth", "test-secret", "--client-secret=test-secret-value", "--export"},
67+
expectError: true,
68+
},
69+
{
70+
name: "missing client-secret source",
71+
args: []string{"create", "secret", "web-auth", "test-secret", "--client-id=test-client", "--export"},
72+
expectError: true,
73+
},
74+
{
75+
name: "multiple client-secret sources",
76+
args: []string{"create", "secret", "web-auth", "test-secret", "--client-id=test-client", "--client-secret=test", "--client-secret-rnd", "--export"},
77+
expectError: true,
78+
},
79+
{
80+
name: "missing secret name",
81+
args: []string{"create", "secret", "web-auth", "--client-id=test-client", "--client-secret=test-secret-value"},
82+
expectError: true,
83+
},
84+
}
85+
86+
for _, tt := range tests {
87+
t.Run(tt.name, func(t *testing.T) {
88+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
89+
defer cancel()
90+
91+
g := NewWithT(t)
92+
93+
kubeconfigArgs.Namespace = &ns.Name
94+
output, err := executeCommand(tt.args)
95+
96+
if tt.expectError {
97+
g.Expect(err).To(HaveOccurred())
98+
return
99+
}
100+
101+
g.Expect(err).ToNot(HaveOccurred())
102+
103+
if tt.expectExport {
104+
obj := &unstructured.Unstructured{}
105+
err = yaml.Unmarshal([]byte(output), &obj.Object)
106+
g.Expect(err).ToNot(HaveOccurred())
107+
108+
g.Expect(obj.GetAPIVersion()).To(Equal("v1"))
109+
g.Expect(obj.GetKind()).To(Equal("Secret"))
110+
g.Expect(obj.GetName()).To(Equal("test-secret"))
111+
g.Expect(obj.GetNamespace()).To(Equal(ns.Name))
112+
113+
secretType, found, err := unstructured.NestedString(obj.Object, "type")
114+
g.Expect(err).ToNot(HaveOccurred())
115+
g.Expect(found).To(BeTrue())
116+
g.Expect(secretType).To(Equal(string(corev1.SecretTypeOpaque)))
117+
118+
stringData, found, err := unstructured.NestedStringMap(obj.Object, "stringData")
119+
g.Expect(err).ToNot(HaveOccurred())
120+
g.Expect(found).To(BeTrue())
121+
g.Expect(stringData).To(HaveKey("client-id"))
122+
g.Expect(stringData).To(HaveKey("client-secret"))
123+
g.Expect(stringData["client-id"]).To(Equal("test-client"))
124+
125+
if tt.name == "create web-auth secret with random and export" {
126+
g.Expect(stringData["client-secret"]).ToNot(BeEmpty())
127+
g.Expect(len(stringData["client-secret"])).To(BeNumerically(">=", 32))
128+
}
129+
130+
// Verify clean export — creationTimestamp should not be present.
131+
_, found, err = unstructured.NestedString(obj.Object, "metadata", "creationTimestamp")
132+
g.Expect(err).ToNot(HaveOccurred())
133+
g.Expect(found).To(BeFalse())
134+
135+
} else {
136+
secretName := "test-secret"
137+
if tt.name == "create web-auth secret with random client-secret" {
138+
secretName = "test-secret-rnd"
139+
}
140+
141+
secret := &corev1.Secret{}
142+
secretKey := types.NamespacedName{Name: secretName, Namespace: ns.Name}
143+
err = testClient.Get(ctx, secretKey, secret)
144+
g.Expect(err).ToNot(HaveOccurred())
145+
146+
g.Expect(secret.Type).To(Equal(corev1.SecretTypeOpaque))
147+
g.Expect(secret.Data).To(HaveKey("client-id"))
148+
g.Expect(secret.Data).To(HaveKey("client-secret"))
149+
g.Expect(string(secret.Data["client-id"])).To(Equal("test-client"))
150+
151+
if tt.name == "create web-auth secret with client-secret" {
152+
g.Expect(string(secret.Data["client-secret"])).To(Equal("test-secret-value"))
153+
}
154+
155+
if tt.name == "create web-auth secret with random client-secret" {
156+
g.Expect(string(secret.Data["client-secret"])).ToNot(BeEmpty())
157+
g.Expect(len(secret.Data["client-secret"])).To(BeNumerically(">=", 32))
158+
}
159+
160+
if tt.name == "create web-auth secret with annotations and labels" {
161+
g.Expect(secret.Annotations).To(HaveKeyWithValue("test.io/annotation", "value"))
162+
g.Expect(secret.Labels).To(HaveKeyWithValue("test.io/label", "value"))
163+
}
164+
165+
if tt.name == "create immutable web-auth secret" {
166+
g.Expect(secret.Immutable).ToNot(BeNil())
167+
g.Expect(*secret.Immutable).To(BeTrue())
168+
}
169+
170+
defer func() {
171+
_ = testClient.Delete(context.Background(), secret)
172+
}()
173+
}
174+
})
175+
}
176+
}

cmd/cli/suite_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ func resetCmdArgs() {
166166
createSecretSSHArgs = createSecretSSHFlags{}
167167
createSecretRegistryArgs = createSecretRegistryFlags{}
168168
createSecretSOPSArgs = createSecretSOPSFlags{}
169+
createSecretWebAuthArgs = createSecretWebAuthFlags{}
169170
webConfigArgs = webConfigFlags{}
170171

171172
// Export commands

0 commit comments

Comments
 (0)