Skip to content

Commit 67dcea0

Browse files
authored
Merge pull request #1220 from fluxcd/jwt-from-path
auth/utils/cijwt: introduce `WithHostTokenFile`
2 parents 1772891 + c701c72 commit 67dcea0

2 files changed

Lines changed: 115 additions & 21 deletions

File tree

auth/utils/cijwt/cijwt.go

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ limitations under the License.
1818
// requests on a per-host basis with a JWT, sourcing the token from a CI/CD
1919
// platform's OIDC integration or signing it locally.
2020
//
21-
// Each configured host gets its token one of three ways:
21+
// Each configured host gets its token one of four ways:
2222
// - WithHostAudience mints an OIDC ID token for the given audience from the
2323
// GitHub/Forgejo Actions token endpoint (see the actionsoidc package),
2424
// caching it for the first 50% of its lifetime and reminting on demand.
2525
// - WithHostToken sends a static JWT as-is, e.g. a GitLab CI id_token injected
2626
// into the job environment.
27+
// - WithHostTokenFile reads the JWT from a file for every request, so a token
28+
// rotated by an external process is picked up without restarting.
2729
// - WithHostJWK signs a fresh, short-lived JWT with a private key from a JWK,
2830
// issuing a new token for every request rather than caching it.
2931
//
@@ -36,6 +38,8 @@ import (
3638
"context"
3739
"fmt"
3840
"net/http"
41+
"os"
42+
"strings"
3943
"sync"
4044
"time"
4145

@@ -62,10 +66,11 @@ type hostJWK struct {
6266
}
6367

6468
type options struct {
65-
inner http.RoundTripper
66-
tokens []hostValue
67-
audiences []hostValue
68-
jwks []hostJWK
69+
inner http.RoundTripper
70+
tokens []hostValue
71+
tokenFiles []hostValue
72+
audiences []hostValue
73+
jwks []hostJWK
6974
}
7075

7176
// Option configures a Transport.
@@ -83,6 +88,15 @@ func WithHostToken(host, token string) Option {
8388
return func(o *options) { o.tokens = append(o.tokens, hostValue{host, token}) }
8489
}
8590

91+
// WithHostTokenFile configures host to be authenticated with a static JWT read
92+
// from path. The file is read on every request, with leading and trailing
93+
// whitespace trimmed, so a token rotated by an external process (e.g. a
94+
// projected service account token) is picked up without restarting. An
95+
// unreadable or empty file errors the request.
96+
func WithHostTokenFile(host, path string) Option {
97+
return func(o *options) { o.tokenFiles = append(o.tokenFiles, hostValue{host, path}) }
98+
}
99+
86100
// WithHostAudience configures host to be authenticated with an OIDC ID token
87101
// minted for the given audience from the GitHub/Forgejo Actions token endpoint,
88102
// cached for the first 50% of its lifetime and reminted on demand.
@@ -115,9 +129,10 @@ type jwkConfig struct {
115129
}
116130

117131
// Transport is an http.RoundTripper that stamps Authorization: Bearer <jwt> on
118-
// requests whose URL host was configured with WithHostToken, WithHostAudience,
119-
// or WithHostJWK. Any existing Authorization header on a configured host is
120-
// overwritten; requests to other hosts pass through untouched.
132+
// requests whose URL host was configured with WithHostToken, WithHostTokenFile,
133+
// WithHostAudience, or WithHostJWK. Any existing Authorization header on a
134+
// configured host is overwritten; requests to other hosts pass through
135+
// untouched.
121136
type Transport struct {
122137
inner http.RoundTripper
123138
// audiences maps a host to the audience minted for it; the factory used on
@@ -126,29 +141,33 @@ type Transport struct {
126141
// jwk maps a host to the signing config used to mint a fresh token for
127142
// every request. It is read-only after construction.
128143
jwk map[string]jwkConfig
144+
// tokenFiles maps a host to a file path read on every request. It is
145+
// read-only after construction.
146+
tokenFiles map[string]string
129147

130148
mu sync.Mutex
131149
cache map[string]cacheEntry
132150
}
133151

134152
// NewTransport returns a Transport configured by opts. At least one host must be
135153
// configured. It returns an error if the same host is configured more than once,
136-
// whether via WithHostToken, WithHostAudience, WithHostJWK, or a mix of them, or
137-
// if a WithHostJWK key fails to parse.
154+
// whether via WithHostToken, WithHostTokenFile, WithHostAudience, WithHostJWK,
155+
// or a mix of them, or if a WithHostJWK key fails to parse.
138156
func NewTransport(opts ...Option) (*Transport, error) {
139157
o := &options{inner: http.DefaultTransport}
140158
for _, opt := range opts {
141159
opt(o)
142160
}
143161

144162
t := &Transport{
145-
inner: o.inner,
146-
audiences: make(map[string]string, len(o.audiences)),
147-
jwk: make(map[string]jwkConfig, len(o.jwks)),
148-
cache: make(map[string]cacheEntry, len(o.tokens)),
163+
inner: o.inner,
164+
audiences: make(map[string]string, len(o.audiences)),
165+
jwk: make(map[string]jwkConfig, len(o.jwks)),
166+
tokenFiles: make(map[string]string, len(o.tokenFiles)),
167+
cache: make(map[string]cacheEntry, len(o.tokens)),
149168
}
150169

151-
seen := make(map[string]bool, len(o.tokens)+len(o.audiences)+len(o.jwks))
170+
seen := make(map[string]bool, len(o.tokens)+len(o.tokenFiles)+len(o.audiences)+len(o.jwks))
152171
claim := func(host string) error {
153172
if seen[host] {
154173
return fmt.Errorf("host %q is configured more than once", host)
@@ -164,6 +183,12 @@ func NewTransport(opts ...Option) (*Transport, error) {
164183
// Seed the cache with a static token that never expires.
165184
t.cache[hv.host] = cacheEntry{token: hv.value}
166185
}
186+
for _, hv := range o.tokenFiles {
187+
if err := claim(hv.host); err != nil {
188+
return nil, err
189+
}
190+
t.tokenFiles[hv.host] = hv.value
191+
}
167192
for _, hv := range o.audiences {
168193
if err := claim(hv.host); err != nil {
169194
return nil, err
@@ -182,7 +207,7 @@ func NewTransport(opts ...Option) (*Transport, error) {
182207
}
183208

184209
if len(seen) == 0 {
185-
return nil, fmt.Errorf("at least one host must be configured with WithHostToken, WithHostAudience, or WithHostJWK")
210+
return nil, fmt.Errorf("at least one host must be configured with WithHostToken, WithHostTokenFile, WithHostAudience, or WithHostJWK")
186211
}
187212

188213
return t, nil
@@ -205,19 +230,32 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
205230
return t.inner.RoundTrip(cloned)
206231
}
207232

208-
// tokenForHost returns the bearer token for host. WithHostJWK hosts get a
209-
// freshly signed token on every call; WithHostAudience hosts are minted and
210-
// cached on a miss. The boolean is false when host was not configured.
233+
// tokenForHost returns the bearer token for host. WithHostJWK and
234+
// WithHostTokenFile hosts get a fresh token on every call; WithHostAudience
235+
// hosts are minted and cached on a miss. The boolean is false when host was
236+
// not configured.
211237
func (t *Transport) tokenForHost(ctx context.Context, host string) (string, bool, error) {
212-
// JWK hosts sign a new token per request and never touch the cache, so they
213-
// need no locking (t.jwk is read-only after construction).
238+
// JWK and token-file hosts produce a fresh token per request and never
239+
// touch the cache, so they need no locking (both maps are read-only after
240+
// construction).
214241
if cfg, ok := t.jwk[host]; ok {
215242
token, err := cfg.key.Issue(cfg.iss, cfg.sub, cfg.aud, jwkTokenTTL)
216243
if err != nil {
217244
return "", false, err
218245
}
219246
return token, true, nil
220247
}
248+
if path, ok := t.tokenFiles[host]; ok {
249+
data, err := os.ReadFile(path)
250+
if err != nil {
251+
return "", false, fmt.Errorf("read token file: %w", err)
252+
}
253+
token := strings.TrimSpace(string(data))
254+
if token == "" {
255+
return "", false, fmt.Errorf("token file %q is empty", path)
256+
}
257+
return token, true, nil
258+
}
221259

222260
t.mu.Lock()
223261
defer t.mu.Unlock()

auth/utils/cijwt/cijwt_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import (
2323
"io"
2424
"net/http"
2525
"net/http/httptest"
26+
"os"
27+
"path/filepath"
2628
"strings"
2729
"sync/atomic"
2830
"testing"
@@ -161,6 +163,14 @@ func TestNewTransport_Validation(t *testing.T) {
161163
},
162164
wantErr: `host "a.example" is configured more than once`,
163165
},
166+
{
167+
name: "duplicate across token and token file",
168+
opts: []cijwt.Option{
169+
cijwt.WithHostToken("a.example", "t"),
170+
cijwt.WithHostTokenFile("a.example", "/path/token"),
171+
},
172+
wantErr: `host "a.example" is configured more than once`,
173+
},
164174
{
165175
name: "invalid jwk",
166176
opts: []cijwt.Option{
@@ -312,6 +322,52 @@ func TestTransport_RoutesPerHost(t *testing.T) {
312322
}
313323
}
314324

325+
func TestTransport_TokenFileReadPerRequest(t *testing.T) {
326+
path := filepath.Join(t.TempDir(), "token")
327+
if err := os.WriteFile(path, []byte("first\n"), 0o600); err != nil {
328+
t.Fatalf("write token: %v", err)
329+
}
330+
rec := &recordingRT{}
331+
tr := mustNewTransport(t, rec, cijwt.WithHostTokenFile("file.example", path))
332+
333+
get(t, tr, "file.example")
334+
if err := os.WriteFile(path, []byte(" second \n"), 0o600); err != nil {
335+
t.Fatalf("rewrite token: %v", err)
336+
}
337+
get(t, tr, "file.example")
338+
339+
want := []string{"Bearer first", "Bearer second"}
340+
if len(rec.auths) != 2 || rec.auths[0] != want[0] || rec.auths[1] != want[1] {
341+
t.Errorf("Authorization = %v, want %v", rec.auths, want)
342+
}
343+
}
344+
345+
func TestTransport_TokenFileMissingErrors(t *testing.T) {
346+
rec := &recordingRT{}
347+
tr := mustNewTransport(t, rec, cijwt.WithHostTokenFile("file.example", filepath.Join(t.TempDir(), "missing")))
348+
349+
req, _ := http.NewRequest(http.MethodGet, "https://file.example/v2/", nil)
350+
_, err := tr.RoundTrip(req)
351+
if err == nil || !strings.Contains(err.Error(), "read token file") {
352+
t.Fatalf("expected read error, got: %v", err)
353+
}
354+
}
355+
356+
func TestTransport_TokenFileEmptyErrors(t *testing.T) {
357+
path := filepath.Join(t.TempDir(), "token")
358+
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
359+
t.Fatalf("write token: %v", err)
360+
}
361+
rec := &recordingRT{}
362+
tr := mustNewTransport(t, rec, cijwt.WithHostTokenFile("file.example", path))
363+
364+
req, _ := http.NewRequest(http.MethodGet, "https://file.example/v2/", nil)
365+
_, err := tr.RoundTrip(req)
366+
if err == nil || !strings.Contains(err.Error(), "is empty") {
367+
t.Fatalf("expected empty error, got: %v", err)
368+
}
369+
}
370+
315371
func TestTransport_JWKSignsFreshTokenPerRequest(t *testing.T) {
316372
const kid = "signing-key"
317373
jwk, pub := makeEdDSAJWK(t, kid)

0 commit comments

Comments
 (0)