Skip to content

Commit 07ecff3

Browse files
committed
auth: extract SA helpers for the CLI
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
1 parent 4439265 commit 07ecff3

6 files changed

Lines changed: 338 additions & 45 deletions

File tree

auth/access_token.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import (
2121
"fmt"
2222
"strings"
2323

24-
authnv1 "k8s.io/api/authentication/v1"
24+
"sigs.k8s.io/controller-runtime/pkg/client"
2525

2626
"github.com/fluxcd/pkg/cache"
2727
)
@@ -60,16 +60,12 @@ func GetAccessToken(ctx context.Context, provider Provider, opts ...Option) (Tok
6060
if saInfo.useServiceAccount {
6161
newAccessToken = func() (Token, error) {
6262
// Issue Kubernetes OIDC token for the service account.
63-
tokenReq := &authnv1.TokenRequest{
64-
Spec: authnv1.TokenRequestSpec{
65-
Audiences: saInfo.audiences,
66-
},
67-
}
68-
if err := o.Client.SubResource("token").Create(ctx, saInfo.obj, tokenReq); err != nil {
63+
saKey := client.ObjectKeyFromObject(saInfo.obj)
64+
oidcToken, err := CreateServiceAccountToken(ctx, o.Client, saKey, saInfo.audiences...)
65+
if err != nil {
6966
return nil, fmt.Errorf("failed to create kubernetes token for service account '%s/%s': %w",
7067
saInfo.obj.Namespace, saInfo.obj.Name, err)
7168
}
72-
oidcToken := tokenReq.Status.Token
7369

7470
// Exchange the Kubernetes OIDC token for a provider access token.
7571
token, err := provider.NewTokenForServiceAccount(ctx, oidcToken, *saInfo.obj, opts...)

auth/pod.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Copyright 2026 The Flux 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 auth
18+
19+
import (
20+
"fmt"
21+
"strings"
22+
23+
"github.com/golang-jwt/jwt/v5"
24+
"sigs.k8s.io/controller-runtime/pkg/client"
25+
)
26+
27+
// FindPodServiceAccount attempts to determine the service account
28+
// associated with the current pod by reading and parsing the
29+
// service account token mounted in the pod.
30+
func FindPodServiceAccount(readFile func(name string) ([]byte, error)) (*client.ObjectKey, error) {
31+
// tokenFile is the well-known path for the service account token mounted
32+
// in pods by Kubernetes.
33+
const tokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
34+
b, err := readFile(tokenFile)
35+
if err != nil {
36+
return nil, fmt.Errorf("failed to read service account token file %s: %w", tokenFile, err)
37+
}
38+
tok, _, err := jwt.NewParser().ParseUnverified(string(b), jwt.MapClaims{})
39+
if err != nil {
40+
return nil, fmt.Errorf("failed to parse service account token: %w", err)
41+
}
42+
sub, err := tok.Claims.GetSubject()
43+
if err != nil {
44+
return nil, fmt.Errorf("failed to get subject from service account token: %w", err)
45+
}
46+
parts := strings.Split(sub, ":")
47+
if len(parts) != 4 {
48+
return nil, fmt.Errorf("invalid subject format in service account token: %s", sub)
49+
}
50+
return &client.ObjectKey{
51+
Namespace: parts[2],
52+
Name: parts[3],
53+
}, nil
54+
}

auth/pod_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
Copyright 2026 The Flux 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 auth_test
18+
19+
import (
20+
"fmt"
21+
"strings"
22+
"testing"
23+
24+
"github.com/golang-jwt/jwt/v5"
25+
26+
"github.com/fluxcd/pkg/auth"
27+
)
28+
29+
func TestFindPodServiceAccount(t *testing.T) {
30+
tests := []struct {
31+
name string
32+
readFile func(string) ([]byte, error)
33+
wantNamespace string
34+
wantName string
35+
wantErr string
36+
}{
37+
{
38+
name: "valid token with correct subject",
39+
readFile: fakeReadFile(t, makeToken(t, jwt.MapClaims{
40+
"sub": "system:serviceaccount:flux-system:source-controller",
41+
})),
42+
wantNamespace: "flux-system",
43+
wantName: "source-controller",
44+
},
45+
{
46+
name: "read file error",
47+
readFile: func(string) ([]byte, error) {
48+
return nil, fmt.Errorf("file not found")
49+
},
50+
wantErr: "failed to read service account token file",
51+
},
52+
{
53+
name: "invalid JWT",
54+
readFile: fakeReadFile(t, "not-a-jwt"),
55+
wantErr: "failed to parse service account token",
56+
},
57+
{
58+
name: "subject with too few parts",
59+
readFile: fakeReadFile(t, makeToken(t, jwt.MapClaims{
60+
"sub": "system:serviceaccount",
61+
})),
62+
wantErr: "invalid subject format",
63+
},
64+
{
65+
name: "subject with too many parts",
66+
readFile: fakeReadFile(t, makeToken(t, jwt.MapClaims{
67+
"sub": "system:serviceaccount:ns:name:extra",
68+
})),
69+
wantErr: "invalid subject format",
70+
},
71+
{
72+
name: "empty subject",
73+
readFile: fakeReadFile(t, makeToken(t, jwt.MapClaims{
74+
"sub": "",
75+
})),
76+
wantErr: "invalid subject format",
77+
},
78+
{
79+
name: "no subject claim",
80+
readFile: fakeReadFile(t, makeToken(t, jwt.MapClaims{
81+
"iss": "kubernetes/serviceaccount",
82+
})),
83+
wantErr: "invalid subject format",
84+
},
85+
{
86+
name: "non-string subject claim",
87+
readFile: fakeReadFile(t, makeToken(t, jwt.MapClaims{
88+
"iss": "kubernetes/serviceaccount",
89+
"sub": 12345,
90+
})),
91+
wantErr: "failed to get subject from service account token",
92+
},
93+
}
94+
95+
for _, tt := range tests {
96+
t.Run(tt.name, func(t *testing.T) {
97+
result, err := auth.FindPodServiceAccount(tt.readFile)
98+
99+
if tt.wantErr != "" {
100+
if err == nil {
101+
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
102+
}
103+
if got := err.Error(); !strings.Contains(got, tt.wantErr) {
104+
t.Errorf("error = %q, want it to contain %q", got, tt.wantErr)
105+
}
106+
return
107+
}
108+
109+
if err != nil {
110+
t.Fatalf("unexpected error: %v", err)
111+
}
112+
if result.Namespace != tt.wantNamespace {
113+
t.Errorf("namespace = %q, want %q", result.Namespace, tt.wantNamespace)
114+
}
115+
if result.Name != tt.wantName {
116+
t.Errorf("name = %q, want %q", result.Name, tt.wantName)
117+
}
118+
})
119+
}
120+
}
121+
122+
func TestFindPodServiceAccount_ReadsCorrectPath(t *testing.T) {
123+
const expectedPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
124+
125+
var gotPath string
126+
readFile := func(name string) ([]byte, error) {
127+
gotPath = name
128+
return []byte(makeToken(t, jwt.MapClaims{
129+
"sub": "system:serviceaccount:default:my-sa",
130+
})), nil
131+
}
132+
133+
_, err := auth.FindPodServiceAccount(readFile)
134+
if err != nil {
135+
t.Fatalf("unexpected error: %v", err)
136+
}
137+
if gotPath != expectedPath {
138+
t.Errorf("read path = %q, want %q", gotPath, expectedPath)
139+
}
140+
}
141+
142+
// makeToken creates an unsigned JWT string with the given claims.
143+
func makeToken(t *testing.T, claims jwt.MapClaims) string {
144+
t.Helper()
145+
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
146+
s, err := tok.SignedString([]byte("test-secret"))
147+
if err != nil {
148+
t.Fatalf("failed to sign test token: %v", err)
149+
}
150+
return s
151+
}
152+
153+
// fakeReadFile returns a readFile function that ignores the path
154+
// and returns the given token string.
155+
func fakeReadFile(t *testing.T, token string) func(string) ([]byte, error) {
156+
t.Helper()
157+
return func(string) ([]byte, error) {
158+
return []byte(token), nil
159+
}
160+
}

auth/service_account.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,36 @@ import (
2020
"context"
2121
"fmt"
2222

23+
authnv1 "k8s.io/api/authentication/v1"
2324
corev1 "k8s.io/api/core/v1"
2425
"k8s.io/apimachinery/pkg/api/errors"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2527
"k8s.io/apimachinery/pkg/types"
2628
"sigs.k8s.io/controller-runtime/pkg/client"
2729
"sigs.k8s.io/yaml"
2830
)
2931

32+
// CreateServiceAccountToken creates a ServiceAccount token for the
33+
// given ServiceAccount reference and audiences.
34+
func CreateServiceAccountToken(ctx context.Context, c client.Client,
35+
saRef client.ObjectKey, audiences ...string) (string, error) {
36+
obj := corev1.ServiceAccount{
37+
ObjectMeta: metav1.ObjectMeta{
38+
Name: saRef.Name,
39+
Namespace: saRef.Namespace,
40+
},
41+
}
42+
tokenReq := &authnv1.TokenRequest{
43+
Spec: authnv1.TokenRequestSpec{
44+
Audiences: audiences,
45+
},
46+
}
47+
if err := c.SubResource("token").Create(ctx, &obj, tokenReq); err != nil {
48+
return "", err
49+
}
50+
return tokenReq.Status.Token, nil
51+
}
52+
3053
// serviceAccountInfo contains the parsed information of the ServiceAccount
3154
// to be used for fetching the access token and generating the cache key when
3255
// object-level workload identity is enabled.

auth/service_account_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
Copyright 2026 The Flux 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 auth_test
18+
19+
import (
20+
"context"
21+
"testing"
22+
"time"
23+
24+
"github.com/golang-jwt/jwt/v5"
25+
. "github.com/onsi/gomega"
26+
corev1 "k8s.io/api/core/v1"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28+
"sigs.k8s.io/controller-runtime/pkg/client"
29+
30+
"github.com/fluxcd/pkg/auth"
31+
)
32+
33+
func TestCreate(t *testing.T) {
34+
g := NewWithT(t)
35+
36+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
37+
t.Cleanup(cancel)
38+
39+
envClient, _ := newTestEnv(t, ctx)
40+
41+
// Create a service account.
42+
sa := &corev1.ServiceAccount{
43+
ObjectMeta: metav1.ObjectMeta{
44+
Name: "test-create-sa",
45+
Namespace: "default",
46+
},
47+
}
48+
g.Expect(envClient.Create(ctx, sa)).NotTo(HaveOccurred())
49+
50+
t.Run("success with audiences", func(t *testing.T) {
51+
g := NewWithT(t)
52+
53+
token, err := auth.CreateServiceAccountToken(ctx, envClient, client.ObjectKey{
54+
Name: "test-create-sa",
55+
Namespace: "default",
56+
}, "audience1", "audience2")
57+
g.Expect(err).NotTo(HaveOccurred())
58+
g.Expect(token).NotTo(BeEmpty())
59+
60+
// Verify the token is a valid JWT with the correct subject and audiences.
61+
jwtToken, _, err := jwt.NewParser().ParseUnverified(token, jwt.MapClaims{})
62+
g.Expect(err).NotTo(HaveOccurred())
63+
sub, err := jwtToken.Claims.GetSubject()
64+
g.Expect(err).NotTo(HaveOccurred())
65+
g.Expect(sub).To(Equal("system:serviceaccount:default:test-create-sa"))
66+
aud, err := jwtToken.Claims.GetAudience()
67+
g.Expect(err).NotTo(HaveOccurred())
68+
g.Expect(aud).To(ConsistOf("audience1", "audience2"))
69+
})
70+
71+
t.Run("success with nil audiences", func(t *testing.T) {
72+
g := NewWithT(t)
73+
74+
token, err := auth.CreateServiceAccountToken(ctx, envClient, client.ObjectKey{
75+
Name: "test-create-sa",
76+
Namespace: "default",
77+
})
78+
g.Expect(err).NotTo(HaveOccurred())
79+
g.Expect(token).NotTo(BeEmpty())
80+
})
81+
82+
t.Run("non-existent service account", func(t *testing.T) {
83+
g := NewWithT(t)
84+
85+
_, err := auth.CreateServiceAccountToken(ctx, envClient, client.ObjectKey{
86+
Name: "nonexistent-sa",
87+
Namespace: "default",
88+
}, "audience1")
89+
g.Expect(err).To(HaveOccurred())
90+
})
91+
}

0 commit comments

Comments
 (0)