Skip to content

Commit 8978c5e

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 <no-reply@ona.com>
1 parent 55bbfe5 commit 8978c5e

File tree

2 files changed

+193
-3
lines changed

2 files changed

+193
-3
lines changed

pkg/leeway/signing/attestation.go

Lines changed: 74 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"
@@ -246,6 +248,17 @@ func signProvenanceWithSigstore(ctx context.Context, statement *in_toto.Statemen
246248

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

271283
// Configure Rekor transparency log
@@ -356,6 +368,66 @@ func validateSigstoreEnvironment() error {
356368
return nil
357369
}
358370

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

pkg/leeway/signing/attestation_test.go

Lines changed: 119 additions & 1 deletion
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/stretchr/testify/assert"
@@ -1138,4 +1141,119 @@ func TestSignProvenanceWithSigstore_EnvironmentValidation(t *testing.T) {
11381141
_, err := GenerateSignedSLSAAttestation(context.Background(), artifactPath, githubCtx)
11391142
assert.Error(t, err)
11401143
assert.Contains(t, err.Error(), "failed to sign SLSA provenance")
1141-
}
1144+
}
1145+
1146+
func TestFetchGitHubOIDCToken(t *testing.T) {
1147+
tests := []struct {
1148+
name string
1149+
setupEnv func(*testing.T)
1150+
mockServer func(*testing.T) *httptest.Server
1151+
audience string
1152+
expectError bool
1153+
errorContains string
1154+
}{
1155+
{
1156+
name: "successful token fetch",
1157+
setupEnv: func(t *testing.T) {
1158+
// Will be set by mockServer
1159+
},
1160+
mockServer: func(t *testing.T) *httptest.Server {
1161+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1162+
// Verify audience parameter
1163+
if r.URL.Query().Get("audience") != "sigstore" {
1164+
t.Errorf("Expected audience=sigstore, got %s", r.URL.Query().Get("audience"))
1165+
}
1166+
// Verify Authorization header
1167+
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
1168+
t.Error("Missing or invalid Authorization header")
1169+
}
1170+
w.WriteHeader(http.StatusOK)
1171+
json.NewEncoder(w).Encode(map[string]string{"value": "test-token-12345"})
1172+
}))
1173+
},
1174+
audience: "sigstore",
1175+
expectError: false,
1176+
},
1177+
{
1178+
name: "missing environment variables",
1179+
setupEnv: func(t *testing.T) {
1180+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "")
1181+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "")
1182+
},
1183+
audience: "sigstore",
1184+
expectError: true,
1185+
errorContains: "GitHub OIDC environment not configured",
1186+
},
1187+
{
1188+
name: "HTTP 500 error",
1189+
mockServer: func(t *testing.T) *httptest.Server {
1190+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1191+
w.WriteHeader(http.StatusInternalServerError)
1192+
w.Write([]byte(`{"error": "internal error"}`))
1193+
}))
1194+
},
1195+
audience: "sigstore",
1196+
expectError: true,
1197+
errorContains: "status: 500",
1198+
},
1199+
{
1200+
name: "empty token in response",
1201+
mockServer: func(t *testing.T) *httptest.Server {
1202+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1203+
w.WriteHeader(http.StatusOK)
1204+
json.NewEncoder(w).Encode(map[string]string{"value": ""})
1205+
}))
1206+
},
1207+
audience: "sigstore",
1208+
expectError: true,
1209+
errorContains: "received empty token",
1210+
},
1211+
{
1212+
name: "invalid JSON response",
1213+
mockServer: func(t *testing.T) *httptest.Server {
1214+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1215+
w.WriteHeader(http.StatusOK)
1216+
w.Write([]byte(`invalid json`))
1217+
}))
1218+
},
1219+
audience: "sigstore",
1220+
expectError: true,
1221+
errorContains: "failed to decode response",
1222+
},
1223+
}
1224+
1225+
for _, tt := range tests {
1226+
t.Run(tt.name, func(t *testing.T) {
1227+
// Setup
1228+
if tt.setupEnv != nil {
1229+
tt.setupEnv(t)
1230+
}
1231+
1232+
var server *httptest.Server
1233+
if tt.mockServer != nil {
1234+
server = tt.mockServer(t)
1235+
defer server.Close()
1236+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", server.URL)
1237+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "test-request-token")
1238+
}
1239+
1240+
// Execute
1241+
ctx := context.Background()
1242+
token, err := fetchGitHubOIDCToken(ctx, tt.audience)
1243+
1244+
// Verify
1245+
if tt.expectError {
1246+
require.Error(t, err)
1247+
if tt.errorContains != "" {
1248+
assert.Contains(t, err.Error(), tt.errorContains)
1249+
}
1250+
} else {
1251+
require.NoError(t, err)
1252+
assert.NotEmpty(t, token)
1253+
if server != nil {
1254+
assert.Equal(t, "test-token-12345", token)
1255+
}
1256+
}
1257+
})
1258+
}
1259+
}

0 commit comments

Comments
 (0)