Skip to content

Commit d7ccd50

Browse files
leodidoona-agent
andcommitted
fix(signing): explicitly fetch GitHub OIDC token for Sigstore
Sigstore-go does not automatically fetch GitHub OIDC tokens from environment variables. This commit adds explicit token fetching logic to resolve signing failures in GitHub Actions. Changes: - Add fetchGitHubOIDCToken() to fetch token from GitHub OIDC endpoint - Update signProvenanceWithSigstore() to use fetched token explicitly - Add comprehensive unit tests for token fetching with error scenarios - Use context-aware HTTP requests with 30s timeout Fixes signing failures where Sigstore expected an explicit IDToken instead of auto-discovering from ACTIONS_ID_TOKEN_REQUEST_* env vars. Co-authored-by: Ona <[email protected]>
1 parent 9a5cbc8 commit d7ccd50

File tree

2 files changed

+200
-2
lines changed

2 files changed

+200
-2
lines changed

pkg/leeway/signing/attestation.go

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"encoding/json"
77
"fmt"
88
"io"
9+
"net/http"
10+
"net/url"
911
"os"
1012
"path/filepath"
1113
"time"
@@ -245,6 +247,17 @@ func signProvenanceWithSigstore(ctx context.Context, statement *in_toto.Statemen
245247

246248
// Configure Fulcio for GitHub OIDC if we have a token
247249
if os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") != "" {
250+
// Fetch the GitHub OIDC token for Sigstore
251+
idToken, err := fetchGitHubOIDCToken(ctx, "sigstore")
252+
if err != nil {
253+
return nil, &SigningError{
254+
Type: ErrorTypeSigstore,
255+
Artifact: statement.Subject[0].Name,
256+
Message: fmt.Sprintf("failed to fetch GitHub OIDC token: %v", err),
257+
Cause: err,
258+
}
259+
}
260+
248261
// Select Fulcio service from signing config
249262
fulcioService, err := root.SelectService(signingConfig.FulcioCertificateAuthorityURLs(), sign.FulcioAPIVersions, time.Now())
250263
if err != nil {
@@ -263,8 +276,7 @@ func signProvenanceWithSigstore(ctx context.Context, statement *in_toto.Statemen
263276
}
264277
bundleOpts.CertificateProvider = sign.NewFulcio(fulcioOpts)
265278
bundleOpts.CertificateProviderOptions = &sign.CertificateProviderOptions{
266-
// Let sigstore-go automatically handle GitHub OIDC
267-
// It will use ACTIONS_ID_TOKEN_REQUEST_TOKEN/URL automatically
279+
IDToken: idToken,
268280
}
269281

270282
// Configure Rekor transparency log
@@ -354,3 +366,71 @@ func validateSigstoreEnvironment() error {
354366
log.Debug("Sigstore environment validation passed")
355367
return nil
356368
}
369+
370+
// fetchGitHubOIDCToken fetches an OIDC token from GitHub Actions for Sigstore.
371+
// It uses the ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL
372+
// environment variables to authenticate and retrieve a JWT token with the specified audience.
373+
func fetchGitHubOIDCToken(ctx context.Context, audience string) (string, error) {
374+
requestURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL")
375+
requestToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
376+
377+
if requestURL == "" || requestToken == "" {
378+
return "", fmt.Errorf("GitHub OIDC environment not configured")
379+
}
380+
381+
// Parse the request URL
382+
u, err := url.Parse(requestURL)
383+
if err != nil {
384+
return "", fmt.Errorf("failed to parse ACTIONS_ID_TOKEN_REQUEST_URL: %w", err)
385+
}
386+
387+
// Add the audience parameter
388+
q := u.Query()
389+
q.Set("audience", audience)
390+
u.RawQuery = q.Encode()
391+
392+
// Create HTTP request with context
393+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
394+
if err != nil {
395+
return "", fmt.Errorf("failed to create request: %w", err)
396+
}
397+
398+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", requestToken))
399+
400+
// Execute request with timeout
401+
client := &http.Client{Timeout: 30 * time.Second}
402+
resp, err := client.Do(req)
403+
if err != nil {
404+
return "", fmt.Errorf("failed to fetch token: %w", err)
405+
}
406+
defer resp.Body.Close()
407+
408+
// Check response status
409+
if resp.StatusCode != http.StatusOK {
410+
bodyBytes, _ := io.ReadAll(resp.Body)
411+
return "", fmt.Errorf("failed to get OIDC token, status: %d, body: %s",
412+
resp.StatusCode, string(bodyBytes))
413+
}
414+
415+
// Parse response
416+
var payload struct {
417+
Value string `json:"value"`
418+
}
419+
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
420+
return "", fmt.Errorf("failed to decode response: %w", err)
421+
}
422+
423+
if payload.Value == "" {
424+
return "", fmt.Errorf("received empty token from GitHub OIDC")
425+
}
426+
427+
return payload.Value, nil
428+
}
429+
430+
// getEnvOrDefault returns environment variable value or default
431+
func getEnvOrDefault(key, defaultValue string) string {
432+
if value := os.Getenv(key); value != "" {
433+
return value
434+
}
435+
return defaultValue
436+
}

pkg/leeway/signing/attestation_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import (
66
"encoding/hex"
77
"encoding/json"
88
"fmt"
9+
"net/http"
10+
"net/http/httptest"
911
"os"
1012
"path/filepath"
13+
"strings"
1114
"testing"
1215

1316
"github.com/gitpod-io/leeway/pkg/leeway/cache"
@@ -1088,3 +1091,118 @@ func TestSignProvenanceWithSigstore_EnvironmentValidation(t *testing.T) {
10881091
assert.Error(t, err)
10891092
assert.Contains(t, err.Error(), "failed to sign SLSA provenance")
10901093
}
1094+
1095+
func TestFetchGitHubOIDCToken(t *testing.T) {
1096+
tests := []struct {
1097+
name string
1098+
setupEnv func(*testing.T)
1099+
mockServer func(*testing.T) *httptest.Server
1100+
audience string
1101+
expectError bool
1102+
errorContains string
1103+
}{
1104+
{
1105+
name: "successful token fetch",
1106+
setupEnv: func(t *testing.T) {
1107+
// Will be set by mockServer
1108+
},
1109+
mockServer: func(t *testing.T) *httptest.Server {
1110+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1111+
// Verify audience parameter
1112+
if r.URL.Query().Get("audience") != "sigstore" {
1113+
t.Errorf("Expected audience=sigstore, got %s", r.URL.Query().Get("audience"))
1114+
}
1115+
// Verify Authorization header
1116+
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
1117+
t.Error("Missing or invalid Authorization header")
1118+
}
1119+
w.WriteHeader(http.StatusOK)
1120+
json.NewEncoder(w).Encode(map[string]string{"value": "test-token-12345"})
1121+
}))
1122+
},
1123+
audience: "sigstore",
1124+
expectError: false,
1125+
},
1126+
{
1127+
name: "missing environment variables",
1128+
setupEnv: func(t *testing.T) {
1129+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "")
1130+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "")
1131+
},
1132+
audience: "sigstore",
1133+
expectError: true,
1134+
errorContains: "GitHub OIDC environment not configured",
1135+
},
1136+
{
1137+
name: "HTTP 500 error",
1138+
mockServer: func(t *testing.T) *httptest.Server {
1139+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1140+
w.WriteHeader(http.StatusInternalServerError)
1141+
w.Write([]byte(`{"error": "internal error"}`))
1142+
}))
1143+
},
1144+
audience: "sigstore",
1145+
expectError: true,
1146+
errorContains: "status: 500",
1147+
},
1148+
{
1149+
name: "empty token in response",
1150+
mockServer: func(t *testing.T) *httptest.Server {
1151+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1152+
w.WriteHeader(http.StatusOK)
1153+
json.NewEncoder(w).Encode(map[string]string{"value": ""})
1154+
}))
1155+
},
1156+
audience: "sigstore",
1157+
expectError: true,
1158+
errorContains: "received empty token",
1159+
},
1160+
{
1161+
name: "invalid JSON response",
1162+
mockServer: func(t *testing.T) *httptest.Server {
1163+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1164+
w.WriteHeader(http.StatusOK)
1165+
w.Write([]byte(`invalid json`))
1166+
}))
1167+
},
1168+
audience: "sigstore",
1169+
expectError: true,
1170+
errorContains: "failed to decode response",
1171+
},
1172+
}
1173+
1174+
for _, tt := range tests {
1175+
t.Run(tt.name, func(t *testing.T) {
1176+
// Setup
1177+
if tt.setupEnv != nil {
1178+
tt.setupEnv(t)
1179+
}
1180+
1181+
var server *httptest.Server
1182+
if tt.mockServer != nil {
1183+
server = tt.mockServer(t)
1184+
defer server.Close()
1185+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", server.URL)
1186+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "test-request-token")
1187+
}
1188+
1189+
// Execute
1190+
ctx := context.Background()
1191+
token, err := fetchGitHubOIDCToken(ctx, tt.audience)
1192+
1193+
// Verify
1194+
if tt.expectError {
1195+
require.Error(t, err)
1196+
if tt.errorContains != "" {
1197+
assert.Contains(t, err.Error(), tt.errorContains)
1198+
}
1199+
} else {
1200+
require.NoError(t, err)
1201+
assert.NotEmpty(t, token)
1202+
if server != nil {
1203+
assert.Equal(t, "test-token-12345", token)
1204+
}
1205+
}
1206+
})
1207+
}
1208+
}

0 commit comments

Comments
 (0)