diff --git a/pkg/api/request.go b/pkg/api/request.go index ea42c4bf..6385ccdc 100644 --- a/pkg/api/request.go +++ b/pkg/api/request.go @@ -8,6 +8,7 @@ import ( "net/http" "charm.land/lipgloss/v2" + "github.com/depot/cli/pkg/runtoken" "github.com/pkg/errors" ) @@ -34,7 +35,7 @@ func apiRequest[Response interface{}](method, url, token string, payload interfa requestBody = nil } - client := &http.Client{} + client := runtoken.WrapClient(&http.Client{}) req, err := http.NewRequest(method, url, requestBody) if err != nil { return nil, err diff --git a/pkg/api/rpc.go b/pkg/api/rpc.go index ef050f91..6e04c339 100644 --- a/pkg/api/rpc.go +++ b/pkg/api/rpc.go @@ -16,6 +16,7 @@ import ( "github.com/depot/cli/pkg/proto/depot/ci/v1/civ1connect" "github.com/depot/cli/pkg/proto/depot/cli/v1/cliv1connect" "github.com/depot/cli/pkg/proto/depot/cli/v1beta1/cliv1beta1connect" + "github.com/depot/cli/pkg/runtoken" "golang.org/x/net/http2" ) @@ -110,19 +111,19 @@ func WithAuthenticationAndOrg[T any](req *connect.Request[T], token, orgID strin func getHTTPClient(baseURL string) *http.Client { if strings.HasPrefix(baseURL, "http://") { // Configure h2c (HTTP/2 over cleartext) for non-TLS connections - return &http.Client{ + return runtoken.WrapClient(&http.Client{ Transport: &http2.Transport{ AllowHTTP: true, DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) { return net.Dial(network, addr) }, }, - } + }) } t, ok := http.DefaultTransport.(*http.Transport) if !ok { - return http.DefaultClient + return runtoken.WrapClient(http.DefaultClient) } transport := t.Clone() @@ -135,7 +136,7 @@ func getHTTPClient(baseURL string) *http.Client { Transport: transport, } - return racingClient + return runtoken.WrapClient(racingClient) } func getBaseURL() string { diff --git a/pkg/cmd/cargo/cargo.go b/pkg/cmd/cargo/cargo.go index 5938699f..47fcf7c6 100644 --- a/pkg/cmd/cargo/cargo.go +++ b/pkg/cmd/cargo/cargo.go @@ -11,6 +11,7 @@ import ( "github.com/depot/cli/pkg/cmd/gocache" "github.com/depot/cli/pkg/config" "github.com/depot/cli/pkg/helpers" + "github.com/depot/cli/pkg/runtoken" "github.com/spf13/cobra" ) @@ -52,10 +53,14 @@ func NewCmdCargo() *cobra.Command { return fmt.Errorf("sccache not found in PATH: %w\n\nPlease install sccache: cargo install sccache", err) } - token, err := helpers.ResolveOrgAuth(ctx, "") + token, err := helpers.ResolveCacheAuth(ctx, "") if err != nil { return fmt.Errorf("failed to resolve token: %w", err) } + // sccache keeps the token for the whole build, and a managed run's own expires in minutes. + if source := runtoken.Default(); source != nil && source.Minted(token) { + return fmt.Errorf("depot cargo needs a long-lived token: pass DEPOT_TOKEN or DEPOT_CACHE_TOKEN") + } // Create cargo command cargoCmd := exec.CommandContext(ctx, "cargo", cargoArgs...) diff --git a/pkg/cmd/docker/docker.go b/pkg/cmd/docker/docker.go index 42e34671..54ee3737 100644 --- a/pkg/cmd/docker/docker.go +++ b/pkg/cmd/docker/docker.go @@ -15,6 +15,7 @@ import ( depotdockerclient "github.com/depot/cli/pkg/dockerclient" "github.com/depot/cli/pkg/helpers" "github.com/depot/cli/pkg/retry" + "github.com/depot/cli/pkg/runtoken" "github.com/docker/buildx/store" "github.com/docker/buildx/store/storeutil" "github.com/docker/buildx/util/confutil" @@ -186,6 +187,10 @@ func runConfigureBuildx(ctx context.Context, dockerCli command.Cli, project, tok if token == "" { return fmt.Errorf("missing API token, please run `depot login`") } + // Buildx saves the token, and a managed run's own expires in minutes. + if source := runtoken.Default(); source != nil && source.Minted(token) { + return fmt.Errorf("configure-docker needs a long-lived token: pass --token or set DEPOT_TOKEN") + } projectName := helpers.ResolveProjectID(project) if projectName == "" { return errors.Errorf("unknown project ID (run `depot init` or use --project or $DEPOT_PROJECT_ID)") diff --git a/pkg/cmd/gocache/gocache.go b/pkg/cmd/gocache/gocache.go index d3821172..abd1c53d 100644 --- a/pkg/cmd/gocache/gocache.go +++ b/pkg/cmd/gocache/gocache.go @@ -21,6 +21,7 @@ import ( "github.com/depot/cli/pkg/cmd/gocache/wire" "github.com/depot/cli/pkg/config" "github.com/depot/cli/pkg/helpers" + "github.com/depot/cli/pkg/runtoken" "github.com/spf13/cobra" ) @@ -49,7 +50,7 @@ func NewCmdGoCache() *cobra.Command { return err } - token, err = helpers.ResolveOrgAuth(ctx, token) + token, err = helpers.ResolveCacheAuth(ctx, token) if err != nil { return err } @@ -336,7 +337,7 @@ func (c *RemoteCache) httpClient() *http.Client { if c.HTTPClient != nil { return c.HTTPClient } - return http.DefaultClient + return runtoken.WrapClient(http.DefaultClient) } func (c *RemoteCache) Get(ctx context.Context, actionID string) (outputID, diskPath string, err error) { diff --git a/pkg/cmd/push/blobs.go b/pkg/cmd/push/blobs.go index ab56e7fb..6d4e8b2f 100644 --- a/pkg/cmd/push/blobs.go +++ b/pkg/cmd/push/blobs.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/depot/cli/pkg/runtoken" "github.com/opencontainers/go-digest" ) @@ -48,7 +49,7 @@ func PushBlob(ctx context.Context, depotToken string, blob *BlobToPush) error { } req.Header.Add("Authorization", "Bearer "+depotToken) req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := runtoken.WrapClient(http.DefaultClient).Do(req) if err != nil { return err } diff --git a/pkg/helpers/token.go b/pkg/helpers/token.go index 4a8ac4ec..25ec0931 100644 --- a/pkg/helpers/token.go +++ b/pkg/helpers/token.go @@ -8,6 +8,7 @@ import ( "github.com/depot/cli/pkg/api" "github.com/depot/cli/pkg/config" "github.com/depot/cli/pkg/oidc" + "github.com/depot/cli/pkg/runtoken" ) func ResolveOrgAuth(ctx context.Context, tok string) (string, error) { @@ -22,6 +23,17 @@ func ResolveOrgAuth(ctx context.Context, tok string) (string, error) { return "", nil } +// ResolveCacheAuth is ResolveOrgAuth for cache tools, which keep a cache token over a run token that expires mid-build. +func ResolveCacheAuth(ctx context.Context, tok string) (string, error) { + token, err := ResolveOrgAuth(ctx, tok) + if source := runTokenSource(); err == nil && source != nil && source.Minted(token) { + if cacheToken := resolveJITToken(); cacheToken != "" { + return cacheToken, nil + } + } + return token, err +} + func ResolveStaticOrgAuth(tok string) string { if tok != "" { return tok @@ -35,6 +47,10 @@ func ResolveStaticOrgAuth(tok string) string { return token } + if token := resolveRunToken(context.Background()); token != "" { + return token + } + return resolveJITToken() } @@ -43,6 +59,10 @@ func ResolveProjectAuth(ctx context.Context, tok string) (string, error) { return token, nil } + if token := resolveRunToken(ctx); token != "" { + return token, nil + } + if token := resolveOIDCToken(ctx); token != "" { return token, nil } @@ -126,6 +146,22 @@ func resolveOIDCToken(ctx context.Context) string { return "" } +var runTokenSource = runtoken.Default + +// resolveRunToken mints a managed run's own token; the API client refreshes it per request. +func resolveRunToken(ctx context.Context) string { + source := runTokenSource() + if source == nil { + return "" + } + token, err := source.Token(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %v\n", err) + return "" + } + return token +} + func resolveJITToken() string { if token := os.Getenv("DEPOT_JIT_TOKEN"); token != "" { return token diff --git a/pkg/helpers/token_test.go b/pkg/helpers/token_test.go new file mode 100644 index 00000000..13ec2641 --- /dev/null +++ b/pkg/helpers/token_test.go @@ -0,0 +1,81 @@ +package helpers + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/depot/cli/pkg/runtoken" + "github.com/spf13/viper" +) + +func withRunTokenServer(t *testing.T) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"value":"run-svid"}`) + })) + t.Cleanup(server.Close) + source := runtoken.New(server.URL) + previous := runTokenSource + runTokenSource = func() *runtoken.Source { return source } + t.Cleanup(func() { runTokenSource = previous }) +} + +func TestRunTokenPrecedence(t *testing.T) { + for _, tc := range []struct { + name, flag, env, config, cache string + runToken bool + want string + }{ + {name: "flag wins", flag: "flag-token", env: "env-token", runToken: true, want: "flag-token"}, + {name: "DEPOT_TOKEN wins", env: "env-token", runToken: true, want: "env-token"}, + {name: "config wins", config: "config-token", runToken: true, want: "config-token"}, + {name: "run token beats cache token", cache: "cache-token", runToken: true, want: "run-svid"}, + {name: "no endpoint keeps old behaviour", cache: "cache-token", want: "cache-token"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("DEPOT_TOKEN", tc.env) + t.Setenv("DEPOT_JIT_TOKEN", "") + t.Setenv("DEPOT_CACHE_TOKEN", tc.cache) + viper.Set("api_token", tc.config) + t.Cleanup(func() { viper.Set("api_token", "") }) + previous := runTokenSource + runTokenSource = func() *runtoken.Source { return nil } + t.Cleanup(func() { runTokenSource = previous }) + if tc.runToken { + withRunTokenServer(t) + } + + if got := ResolveStaticOrgAuth(tc.flag); got != tc.want { + t.Fatalf("ResolveStaticOrgAuth = %q, want %q", got, tc.want) + } + if tc.cache == "" { + if got, _ := ResolveProjectAuth(context.Background(), tc.flag); got != tc.want { + t.Fatalf("ResolveProjectAuth = %q, want %q", got, tc.want) + } + } + }) + } +} + +func TestResolveCacheAuthKeepsCacheToken(t *testing.T) { + t.Setenv("DEPOT_TOKEN", "") + t.Setenv("DEPOT_JIT_TOKEN", "") + t.Setenv("DEPOT_CACHE_TOKEN", "cache-token") + viper.Set("api_token", "") + withRunTokenServer(t) + + if got, _ := ResolveOrgAuth(context.Background(), ""); got != "run-svid" { + t.Fatalf("ResolveOrgAuth = %q, want the run token", got) + } + if got, _ := ResolveCacheAuth(context.Background(), ""); got != "cache-token" { + t.Fatalf("ResolveCacheAuth = %q, want the cache token", got) + } + + t.Setenv("DEPOT_CACHE_TOKEN", "") + if got, _ := ResolveCacheAuth(context.Background(), ""); got != "run-svid" { + t.Fatalf("ResolveCacheAuth without a cache token = %q, want the run token", got) + } +} diff --git a/pkg/runtoken/runtoken.go b/pkg/runtoken/runtoken.go new file mode 100644 index 00000000..bafb2026 --- /dev/null +++ b/pkg/runtoken/runtoken.go @@ -0,0 +1,172 @@ +// Package runtoken mints the Depot API token of a Depot managed run from its sandbox identity (DEP-7956). +package runtoken + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" +) + +// EnvURL names the sandbox endpoint that answers with a fresh JWT-SVID as {"value": ""}. +const EnvURL = "DEPOT_RUN_TOKEN_URL" + +// An SVID lives five minutes, so one this close to expiry is replaced before use. +const refreshBefore = time.Minute + +type Source struct { + url string + client *http.Client + now func() time.Time + + mu sync.Mutex + token string + expiry time.Time + minted map[string]struct{} +} + +func New(url string) *Source { + return &Source{ + url: url, + // The endpoint is link-local: a configured proxy cannot reach it. + client: &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{Proxy: nil}}, + now: time.Now, + minted: map[string]struct{}{}, + } +} + +var defaultSource = sync.OnceValue(func() *Source { + if url := os.Getenv(EnvURL); url != "" { + return New(url) + } + return nil +}) + +// Default is the Source named by DEPOT_RUN_TOKEN_URL, or nil outside a managed run. +func Default() *Source { + return defaultSource() +} + +// Token returns the cached token, minting a new one when it is near expiry. +func (s *Source) Token(ctx context.Context) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.token != "" && s.now().Add(refreshBefore).Before(s.expiry) { + return s.token, nil + } + + token, err := s.fetch(ctx) + if err != nil { + // A failed refresh keeps a token that has not expired yet. + if s.token != "" && s.now().Before(s.expiry) { + return s.token, nil + } + return "", err + } + s.token, s.expiry = token, expiry(token) + s.minted[token] = struct{}{} + return token, nil +} + +// Minted reports whether token came from this Source, so callers replace only tokens it owns. +func (s *Source) Minted(token string) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.minted[token] + return ok +} + +func (s *Source) fetch(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil) + if err != nil { + return "", err + } + resp, err := s.client.Do(req) + if err != nil { + return "", fmt.Errorf("fetch run token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if err != nil { + return "", fmt.Errorf("read run token: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("fetch run token: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var out struct { + Value string `json:"value"` + } + if err := json.Unmarshal(body, &out); err != nil { + return "", fmt.Errorf("decode run token: %w", err) + } + if out.Value == "" { + return "", fmt.Errorf("fetch run token: empty token") + } + return out.Value, nil +} + +// expiry reads the JWT's exp claim unverified; a token without one is treated as already expiring. +func expiry(token string) time.Time { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{} + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{} + } + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp == 0 { + return time.Time{} + } + return time.Unix(claims.Exp, 0) +} + +// Transport swaps a Bearer token this Source minted for a current one on every request. +type Transport struct { + Source *Source + Base http.RoundTripper +} + +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + token, ok := strings.CutPrefix(req.Header.Get("Authorization"), "Bearer ") + if !ok || !t.Source.Minted(token) { + return t.Base.RoundTrip(req) + } + + fresh, err := t.Source.Token(req.Context()) + if err != nil { + return nil, err + } + if fresh != token { + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer "+fresh) + } + return t.Base.RoundTrip(req) +} + +// WrapClient returns client with run token refresh, or client itself outside a managed run. +func WrapClient(client *http.Client) *http.Client { + source := Default() + if source == nil { + return client + } + base := client.Transport + if base == nil { + base = http.DefaultTransport + } + wrapped := *client + wrapped.Transport = &Transport{Source: source, Base: base} + return &wrapped +} diff --git a/pkg/runtoken/runtoken_test.go b/pkg/runtoken/runtoken_test.go new file mode 100644 index 00000000..e157c2ce --- /dev/null +++ b/pkg/runtoken/runtoken_test.go @@ -0,0 +1,176 @@ +package runtoken + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func jwt(exp time.Time, n int64) string { + payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"exp":%d,"n":%d}`, exp.Unix(), n))) + return "e30." + payload + ".sig" +} + +// identityServer stands in for the sandbox endpoint, minting a new five-minute SVID per call. +func identityServer(t *testing.T, now func() time.Time) (*httptest.Server, *atomic.Int64) { + t.Helper() + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("audience") != "managed-agent-run-api" { + http.Error(w, "missing audience parameter", http.StatusBadRequest) + return + } + n := calls.Add(1) + fmt.Fprintf(w, `{"value":%q}`, jwt(now().Add(5*time.Minute), n)) + })) + t.Cleanup(server.Close) + return server, &calls +} + +func newTestSource(t *testing.T) (*Source, *time.Time, *atomic.Int64) { + t.Helper() + clock := time.Unix(1_800_000_000, 0) + now := func() time.Time { return clock } + server, calls := identityServer(t, now) + source := New(server.URL + "/token?audience=managed-agent-run-api") + source.now = now + return source, &clock, calls +} + +func TestTokenCachesUntilNearExpiry(t *testing.T) { + source, clock, calls := newTestSource(t) + ctx := context.Background() + + first, err := source.Token(ctx) + if err != nil { + t.Fatal(err) + } + *clock = clock.Add(3 * time.Minute) + if again, _ := source.Token(ctx); again != first || calls.Load() != 1 { + t.Fatalf("expected cached token at 3m, got %d fetches", calls.Load()) + } + + *clock = clock.Add(90 * time.Second) + refreshed, err := source.Token(ctx) + if err != nil { + t.Fatal(err) + } + if refreshed == first || calls.Load() != 2 { + t.Fatalf("expected a fresh token within a minute of expiry, got %d fetches", calls.Load()) + } + if !source.Minted(first) || !source.Minted(refreshed) || source.Minted("user-token") { + t.Fatal("Minted must recognise exactly the tokens this source issued") + } +} + +func TestTokenWithoutExpiryIsRefetched(t *testing.T) { + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + fmt.Fprint(w, `{"value":"opaque"}`) + })) + defer server.Close() + source := New(server.URL) + for range 2 { + if token, err := source.Token(context.Background()); err != nil || token != "opaque" { + t.Fatalf("got %q, %v", token, err) + } + } + if calls.Load() != 2 { + t.Fatalf("expected a fetch per call, got %d", calls.Load()) + } +} + +func TestFailedRefreshKeepsUnexpiredToken(t *testing.T) { + clock := time.Unix(1_800_000_000, 0) + var fail atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if fail.Load() { + http.Error(w, "no identity", http.StatusServiceUnavailable) + return + } + fmt.Fprintf(w, `{"value":%q}`, jwt(clock.Add(5*time.Minute), 1)) + })) + defer server.Close() + source := New(server.URL) + source.now = func() time.Time { return clock } + + first, err := source.Token(context.Background()) + if err != nil { + t.Fatal(err) + } + fail.Store(true) + clock = clock.Add(4*time.Minute + 30*time.Second) + if token, err := source.Token(context.Background()); err != nil || token != first { + t.Fatalf("inside the refresh window a failed fetch should keep the token, got %q, %v", token, err) + } + clock = clock.Add(time.Minute) + if _, err := source.Token(context.Background()); err == nil { + t.Fatal("an expired token must not be returned when the refresh fails") + } +} + +func TestTokenErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no identity", http.StatusServiceUnavailable) + })) + defer server.Close() + if _, err := New(server.URL).Token(context.Background()); err == nil { + t.Fatal("expected an error from a failing endpoint") + } +} + +func TestTransportRefreshesMintedTokenPerRequest(t *testing.T) { + source, clock, _ := newTestSource(t) + var seen []string + api := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Header.Get("Authorization")) + })) + defer api.Close() + client := &http.Client{Transport: &Transport{Source: source, Base: http.DefaultTransport}} + + // The command resolves its token once at startup, as every caller does today. + startup, err := source.Token(context.Background()) + if err != nil { + t.Fatal(err) + } + get := func(token string) { + req, _ := http.NewRequest(http.MethodGet, api.URL, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + } + + get(startup) + *clock = clock.Add(10 * time.Minute) + get(startup) + get("explicit-token") + + if seen[0] != "Bearer "+startup { + t.Fatalf("first request should carry the startup token, got %q", seen[0]) + } + if seen[1] == "Bearer "+startup || !source.Minted(seen[1][len("Bearer "):]) { + t.Fatalf("request after expiry should carry a fresh token, got %q", seen[1]) + } + if seen[2] != "Bearer explicit-token" { + t.Fatalf("a token the source did not mint must pass through, got %q", seen[2]) + } +} + +func TestWrapClientWithoutEnvIsUnchanged(t *testing.T) { + if Default() != nil { + t.Skip(EnvURL + " is set in this environment") + } + client := &http.Client{} + if WrapClient(client) != client { + t.Fatal("outside a managed run the client must be returned as is") + } +}