Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 59 additions & 21 deletions auth/utils/cijwt/cijwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ limitations under the License.
// requests on a per-host basis with a JWT, sourcing the token from a CI/CD
// platform's OIDC integration or signing it locally.
//
// Each configured host gets its token one of three ways:
// Each configured host gets its token one of four ways:
// - WithHostAudience mints an OIDC ID token for the given audience from the
// GitHub/Forgejo Actions token endpoint (see the actionsoidc package),
// caching it for the first 50% of its lifetime and reminting on demand.
// - WithHostToken sends a static JWT as-is, e.g. a GitLab CI id_token injected
// into the job environment.
// - WithHostTokenFile reads the JWT from a file for every request, so a token
// rotated by an external process is picked up without restarting.
// - WithHostJWK signs a fresh, short-lived JWT with a private key from a JWK,
// issuing a new token for every request rather than caching it.
//
Expand All @@ -36,6 +38,8 @@ import (
"context"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"

Expand All @@ -62,10 +66,11 @@ type hostJWK struct {
}

type options struct {
inner http.RoundTripper
tokens []hostValue
audiences []hostValue
jwks []hostJWK
inner http.RoundTripper
tokens []hostValue
tokenFiles []hostValue
audiences []hostValue
jwks []hostJWK
}

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

// WithHostTokenFile configures host to be authenticated with a static JWT read
// from path. The file is read on every request, with leading and trailing
// whitespace trimmed, so a token rotated by an external process (e.g. a
// projected service account token) is picked up without restarting. An
// unreadable or empty file errors the request.
func WithHostTokenFile(host, path string) Option {
return func(o *options) { o.tokenFiles = append(o.tokenFiles, hostValue{host, path}) }
}

// WithHostAudience configures host to be authenticated with an OIDC ID token
// minted for the given audience from the GitHub/Forgejo Actions token endpoint,
// cached for the first 50% of its lifetime and reminted on demand.
Expand Down Expand Up @@ -115,9 +129,10 @@ type jwkConfig struct {
}

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

mu sync.Mutex
cache map[string]cacheEntry
}

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

t := &Transport{
inner: o.inner,
audiences: make(map[string]string, len(o.audiences)),
jwk: make(map[string]jwkConfig, len(o.jwks)),
cache: make(map[string]cacheEntry, len(o.tokens)),
inner: o.inner,
audiences: make(map[string]string, len(o.audiences)),
jwk: make(map[string]jwkConfig, len(o.jwks)),
tokenFiles: make(map[string]string, len(o.tokenFiles)),
cache: make(map[string]cacheEntry, len(o.tokens)),
}

seen := make(map[string]bool, len(o.tokens)+len(o.audiences)+len(o.jwks))
seen := make(map[string]bool, len(o.tokens)+len(o.tokenFiles)+len(o.audiences)+len(o.jwks))
claim := func(host string) error {
if seen[host] {
return fmt.Errorf("host %q is configured more than once", host)
Expand All @@ -164,6 +183,12 @@ func NewTransport(opts ...Option) (*Transport, error) {
// Seed the cache with a static token that never expires.
t.cache[hv.host] = cacheEntry{token: hv.value}
}
for _, hv := range o.tokenFiles {
if err := claim(hv.host); err != nil {
return nil, err
}
t.tokenFiles[hv.host] = hv.value
}
for _, hv := range o.audiences {
if err := claim(hv.host); err != nil {
return nil, err
Expand All @@ -182,7 +207,7 @@ func NewTransport(opts ...Option) (*Transport, error) {
}

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

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

// tokenForHost returns the bearer token for host. WithHostJWK hosts get a
// freshly signed token on every call; WithHostAudience hosts are minted and
// cached on a miss. The boolean is false when host was not configured.
// tokenForHost returns the bearer token for host. WithHostJWK and
// WithHostTokenFile hosts get a fresh token on every call; WithHostAudience
// hosts are minted and cached on a miss. The boolean is false when host was
// not configured.
func (t *Transport) tokenForHost(ctx context.Context, host string) (string, bool, error) {
// JWK hosts sign a new token per request and never touch the cache, so they
// need no locking (t.jwk is read-only after construction).
// JWK and token-file hosts produce a fresh token per request and never
// touch the cache, so they need no locking (both maps are read-only after
// construction).
if cfg, ok := t.jwk[host]; ok {
token, err := cfg.key.Issue(cfg.iss, cfg.sub, cfg.aud, jwkTokenTTL)
if err != nil {
return "", false, err
}
return token, true, nil
}
if path, ok := t.tokenFiles[host]; ok {
data, err := os.ReadFile(path)
if err != nil {
return "", false, fmt.Errorf("read token file: %w", err)
}
token := strings.TrimSpace(string(data))
if token == "" {
return "", false, fmt.Errorf("token file %q is empty", path)
}
return token, true, nil
}

t.mu.Lock()
defer t.mu.Unlock()
Expand Down
56 changes: 56 additions & 0 deletions auth/utils/cijwt/cijwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -161,6 +163,14 @@ func TestNewTransport_Validation(t *testing.T) {
},
wantErr: `host "a.example" is configured more than once`,
},
{
name: "duplicate across token and token file",
opts: []cijwt.Option{
cijwt.WithHostToken("a.example", "t"),
cijwt.WithHostTokenFile("a.example", "/path/token"),
},
wantErr: `host "a.example" is configured more than once`,
},
{
name: "invalid jwk",
opts: []cijwt.Option{
Expand Down Expand Up @@ -312,6 +322,52 @@ func TestTransport_RoutesPerHost(t *testing.T) {
}
}

func TestTransport_TokenFileReadPerRequest(t *testing.T) {
path := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(path, []byte("first\n"), 0o600); err != nil {
t.Fatalf("write token: %v", err)
}
rec := &recordingRT{}
tr := mustNewTransport(t, rec, cijwt.WithHostTokenFile("file.example", path))

get(t, tr, "file.example")
if err := os.WriteFile(path, []byte(" second \n"), 0o600); err != nil {
t.Fatalf("rewrite token: %v", err)
}
get(t, tr, "file.example")

want := []string{"Bearer first", "Bearer second"}
if len(rec.auths) != 2 || rec.auths[0] != want[0] || rec.auths[1] != want[1] {
t.Errorf("Authorization = %v, want %v", rec.auths, want)
}
}

func TestTransport_TokenFileMissingErrors(t *testing.T) {
rec := &recordingRT{}
tr := mustNewTransport(t, rec, cijwt.WithHostTokenFile("file.example", filepath.Join(t.TempDir(), "missing")))

req, _ := http.NewRequest(http.MethodGet, "https://file.example/v2/", nil)
_, err := tr.RoundTrip(req)
if err == nil || !strings.Contains(err.Error(), "read token file") {
t.Fatalf("expected read error, got: %v", err)
}
}

func TestTransport_TokenFileEmptyErrors(t *testing.T) {
path := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
t.Fatalf("write token: %v", err)
}
rec := &recordingRT{}
tr := mustNewTransport(t, rec, cijwt.WithHostTokenFile("file.example", path))

req, _ := http.NewRequest(http.MethodGet, "https://file.example/v2/", nil)
_, err := tr.RoundTrip(req)
if err == nil || !strings.Contains(err.Error(), "is empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}

func TestTransport_JWKSignsFreshTokenPerRequest(t *testing.T) {
const kid = "signing-key"
jwk, pub := makeEdDSAJWK(t, kid)
Expand Down
Loading