Skip to content

Commit 463e160

Browse files
authored
Merge pull request #21 from spiffe/hint
Add hint support
2 parents 1343797 + 686df19 commit 463e160

4 files changed

Lines changed: 216 additions & 3 deletions

File tree

.github/workflows/test.yaml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,17 @@ jobs:
2525
uses: golangci/golangci-lint-action@v7
2626
with:
2727
version: v2.12.2
28-
skip-go-installation: true
28+
29+
test:
30+
runs-on: ubuntu-latest
31+
permissions:
32+
contents: read
33+
steps:
34+
- name: Checkout
35+
uses: actions/checkout@v4
36+
- name: Set up Go
37+
uses: actions/setup-go@v5
38+
with:
39+
go-version-file: 'go.mod'
40+
- name: Test
41+
run: go test ./...

README.md

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,45 @@ A Kubernetes exec auth plugin using the SPIFFE Workload API to get JWTs for auth
88
## Building
99

1010
```
11-
go build .
11+
go build -o k8s-spiffe-workload-jwt-exec-auth ./cmd
1212
```
1313

14+
## Configuration
15+
16+
The plugin is configured entirely through environment variables, set via the `env:` list of the
17+
kubeconfig `exec` block:
18+
19+
| Variable | Default | Description |
20+
| --- | --- | --- |
21+
| `SPIFFE_ENDPOINT_SOCKET` | `unix:///tmp/spire-agent/public/api.sock` | Address of the SPIFFE Workload API socket. |
22+
| `SPIFFE_JWT_AUDIENCE` | `k8s` | Audience requested for the JWT-SVID. Must match an entry in the API server's `AuthenticationConfiguration`. |
23+
| `SPIFFE_JWT_HINT` | *(unset)* | Selects which JWT-SVID to use by hint, when the Workload API returns more than one. See below. |
24+
| `EXEC_CREDENTIAL_VERSION` | `v1` | The `client.authentication.k8s.io` version emitted. Use `v1beta1` for older clients. Must match the `apiVersion` in the `exec` block. |
25+
26+
There is also one flag, passed via `args:` rather than `env:`:
27+
28+
| Flag | Default | Description |
29+
| --- | --- | --- |
30+
| `-timeout` | `0` | Max time to wait for the JWT-SVID from the Workload API socket, e.g. `-timeout=5s`. `0` waits forever. |
31+
32+
### Selecting an identity with `SPIFFE_JWT_HINT`
33+
34+
Hints are operator-set strings on SPIRE registration entries, used "to provide guidance on how this
35+
identity should be used by a workload when more than one SVID is returned". If the Workload API
36+
returns several JWT-SVIDs — for example a SPIRE HA broker fronting multiple entry-scoped SVIDs — then
37+
which one comes first is arbitrary, and the plugin may authenticate to the cluster as an identity you
38+
did not intend.
39+
40+
Set `SPIFFE_JWT_HINT` to pin a specific one:
41+
42+
- **Unset or empty**: use the first JWT-SVID returned. This is the original behavior.
43+
- **Set and matched**: use the JWT-SVID with that hint.
44+
- **Set and unmatched**: exit non-zero with an error on stderr listing the hints that *were*
45+
available, rather than silently authenticating as a different identity.
46+
47+
Matching is exact — no case folding or whitespace trimming. SPIRE keeps only the first SVID for each
48+
non-empty hint, so hints are effectively unique.
49+
1450
## Usage
1551

1652
### Setup the Kubernetes cluster auth
@@ -50,6 +86,10 @@ Remove the "user" block from the "users" section and replace it with:
5086
# value: "unix:///var/run/spire/agent/sockets/main/public/api.sock"
5187
# - name: SPIFFE_JWT_AUDIENCE
5288
# value: "k8s-one"
89+
# - name: SPIFFE_JWT_HINT
90+
# value: "k8s-one"
91+
#args:
92+
# - "-timeout=5s"
5393
```
5494

5595
### Kubelet kubeconfig file
@@ -68,4 +108,8 @@ Modify `/etc/kubernetes/kubelet.conf`, and remove `client-certificate` and `clie
68108
# value: "unix:///var/run/spire/agent/sockets/main/public/api.sock"
69109
# - name: SPIFFE_JWT_AUDIENCE
70110
# value: "k8s-one"
111+
# - name: SPIFFE_JWT_HINT
112+
# value: "k8s-one"
113+
#args:
114+
# - "-timeout=5s"
71115
```

cmd/main.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package main
22

33
import (
44
"context"
5+
"errors"
56
"flag"
67
"fmt"
78
"log"
89
"os"
10+
"strings"
911
"time"
1012

1113
"github.com/spiffe/go-spiffe/v2/svid/jwtsvid"
@@ -15,6 +17,12 @@ import (
1517
)
1618

1719
func main() {
20+
// client-go inherits this process's stderr and reports only "failed with exit
21+
// code N", so anything logged here is the operator's entire diagnostic. Drop
22+
// log's timestamp and name the plugin instead.
23+
log.SetFlags(0)
24+
log.SetPrefix("k8s-spiffe-workload-jwt-exec-auth: ")
25+
1826
timeout := flag.Duration("timeout", 0,
1927
"max time to wait for the JWT-SVID from the workload API socket (e.g. 5s). 0 = wait forever")
2028
flag.Parse()
@@ -29,6 +37,12 @@ func main() {
2937
audience = "k8s"
3038
}
3139

40+
// Unset or empty means "no preference": use the first JWT-SVID the Workload API
41+
// returns, which is what this plugin has always done. Set this when the Workload
42+
// API returns several JWT-SVIDs (for example a SPIRE HA broker fronting multiple
43+
// entry-scoped SVIDs) to pin a specific one.
44+
hint := os.Getenv("SPIFFE_JWT_HINT")
45+
3246
execCredentialVersion, ok := os.LookupEnv("EXEC_CREDENTIAL_VERSION")
3347
if !ok {
3448
execCredentialVersion = "v1"
@@ -49,12 +63,19 @@ func main() {
4963
log.Fatal(err)
5064

5165
}
52-
svid, err := jwtSource.FetchJWTSVID(ctx, jwtsvid.Params{
66+
svids, err := jwtSource.FetchJWTSVIDs(ctx, jwtsvid.Params{
5367
Audience: audience,
5468
})
5569
if err != nil {
5670
log.Fatal(err)
5771
}
72+
// Everything that can fail must fail before the first write to stdout below:
73+
// client-go decodes stdout as an ExecCredential, so a half-written document
74+
// would surface as a confusing decode error instead of the real problem.
75+
svid, err := selectSVIDByHint(svids, hint)
76+
if err != nil {
77+
log.Fatal(err)
78+
}
5879

5980
now := time.Now()
6081
expiry, err := metav1.NewTime(credentialExpiration(now, svid.Expiry)).MarshalJSON()
@@ -73,3 +94,27 @@ func main() {
7394
func credentialExpiration(now, jwtExpiry time.Time) time.Time {
7495
return now.Add(jwtExpiry.Sub(now) / 2)
7596
}
97+
98+
// selectSVIDByHint returns the JWT-SVID whose hint matches the requested hint. An
99+
// empty hint means no preference, in which case the first JWT-SVID is returned. If
100+
// a hint is requested but nothing matches, an error naming the available hints is
101+
// returned rather than a JWT-SVID for a different identity.
102+
func selectSVIDByHint(svids []*jwtsvid.SVID, hint string) (*jwtsvid.SVID, error) {
103+
if len(svids) == 0 {
104+
return nil, errors.New("the workload API returned no JWT-SVIDs")
105+
}
106+
if hint == "" {
107+
return svids[0], nil
108+
}
109+
110+
available := make([]string, 0, len(svids))
111+
for _, svid := range svids {
112+
if svid.Hint == hint {
113+
return svid, nil
114+
}
115+
available = append(available, fmt.Sprintf("%q", svid.Hint))
116+
}
117+
118+
return nil, fmt.Errorf("no JWT-SVID with hint %q (SPIFFE_JWT_HINT); available hints: %s",
119+
hint, strings.Join(available, ", "))
120+
}

cmd/main_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package main
22

33
import (
4+
"strings"
45
"testing"
56
"time"
7+
8+
"github.com/spiffe/go-spiffe/v2/svid/jwtsvid"
69
)
710

811
func TestCredentialExpirationIsHalfwayToJWTExpiry(t *testing.T) {
@@ -14,3 +17,111 @@ func TestCredentialExpirationIsHalfwayToJWTExpiry(t *testing.T) {
1417
t.Fatalf("credentialExpiration() = %s, want %s", got, want)
1518
}
1619
}
20+
21+
func TestSelectSVIDByHint(t *testing.T) {
22+
// Named variables rather than a constructor helper, so the cases below can
23+
// assert which of two indistinguishable SVIDs was picked by pointer identity.
24+
one := &jwtsvid.SVID{Hint: "one"}
25+
two := &jwtsvid.SVID{Hint: "two"}
26+
three := &jwtsvid.SVID{Hint: "three"}
27+
upperOne := &jwtsvid.SVID{Hint: "One"}
28+
dupFirst := &jwtsvid.SVID{Hint: "dup"}
29+
dupSecond := &jwtsvid.SVID{Hint: "dup"}
30+
blankFirst := &jwtsvid.SVID{}
31+
blankSecond := &jwtsvid.SVID{}
32+
33+
tests := []struct {
34+
name string
35+
svids []*jwtsvid.SVID
36+
hint string
37+
want *jwtsvid.SVID
38+
// wantErrs lists substrings the error must contain. Empty means expect success.
39+
wantErrs []string
40+
}{
41+
{
42+
name: "no hint returns the only SVID",
43+
svids: []*jwtsvid.SVID{blankFirst},
44+
want: blankFirst,
45+
},
46+
{
47+
name: "no hint returns the first of many",
48+
svids: []*jwtsvid.SVID{one, two},
49+
want: one,
50+
},
51+
{
52+
name: "hint matches the first SVID",
53+
svids: []*jwtsvid.SVID{one, two},
54+
hint: "one",
55+
want: one,
56+
},
57+
{
58+
name: "hint matches a later SVID",
59+
svids: []*jwtsvid.SVID{one, two, three},
60+
hint: "three",
61+
want: three,
62+
},
63+
{
64+
name: "duplicate hints return the first",
65+
svids: []*jwtsvid.SVID{dupFirst, dupSecond},
66+
hint: "dup",
67+
want: dupFirst,
68+
},
69+
{
70+
name: "unmatched hint errors and lists the available hints",
71+
svids: []*jwtsvid.SVID{one, two},
72+
hint: "three",
73+
wantErrs: []string{`"three"`, `"one"`, `"two"`, "SPIFFE_JWT_HINT"},
74+
},
75+
{
76+
name: "unmatched hint lists unhinted SVIDs as empty",
77+
svids: []*jwtsvid.SVID{blankFirst, blankSecond},
78+
hint: "one",
79+
wantErrs: []string{`"one"`, `"", ""`},
80+
},
81+
{
82+
name: "hint matching is case sensitive",
83+
svids: []*jwtsvid.SVID{upperOne},
84+
hint: "one",
85+
wantErrs: []string{`"one"`, `"One"`},
86+
},
87+
{
88+
name: "no SVIDs errors",
89+
svids: nil,
90+
wantErrs: []string{"no JWT-SVIDs"},
91+
},
92+
{
93+
name: "no SVIDs errors when a hint is requested",
94+
svids: []*jwtsvid.SVID{},
95+
hint: "one",
96+
wantErrs: []string{"no JWT-SVIDs"},
97+
},
98+
}
99+
100+
for _, tt := range tests {
101+
t.Run(tt.name, func(t *testing.T) {
102+
got, err := selectSVIDByHint(tt.svids, tt.hint)
103+
104+
if len(tt.wantErrs) == 0 {
105+
if err != nil {
106+
t.Fatalf("selectSVIDByHint() returned unexpected error: %v", err)
107+
}
108+
if got != tt.want {
109+
t.Fatalf("selectSVIDByHint() = %+v, want %+v", got, tt.want)
110+
}
111+
return
112+
}
113+
114+
if err == nil {
115+
t.Fatalf("selectSVIDByHint() = %+v, want an error", got)
116+
}
117+
if got != nil {
118+
t.Errorf("selectSVIDByHint() returned %+v alongside an error, want nil", got)
119+
}
120+
for _, want := range tt.wantErrs {
121+
if !strings.Contains(err.Error(), want) {
122+
t.Errorf("selectSVIDByHint() error = %q, want it to contain %s", err, want)
123+
}
124+
}
125+
})
126+
}
127+
}

0 commit comments

Comments
 (0)