@@ -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
6468type 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.
121136type 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.
138156func 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.
211237func (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 ()
0 commit comments