diff --git a/command/report/report.go b/command/report/report.go index 30bc954e..364c9dbc 100644 --- a/command/report/report.go +++ b/command/report/report.go @@ -25,6 +25,11 @@ type ReportOptions struct { ValueFile string SkipCertificateVerification bool DSN string + UseOIDC bool + OIDCRequestToken string // id token to manually get an OIDC token + OIDCRequestUrl string // url to manually get an OIDC token + DeepSourceHostEndpoint string // DeepSource host endpoint where the app is running. Defaults to the cloud endpoint https://app.deepsource.com + OIDCProvider string // OIDC provider to use for authentication } // NewCmdVersion returns the current version of cli being used @@ -67,6 +72,14 @@ func NewCmdReport() *cobra.Command { cmd.Flags().StringVar(&opts.ValueFile, "value-file", "", "path to the artifact value file") + cmd.Flags().BoolVar(&opts.UseOIDC, "use-oidc", false, "use OIDC to authenticate with DeepSource") + + cmd.Flags().StringVar(&opts.OIDCRequestToken, "oidc-request-token", "", "request ID token to fetch an OIDC token from OIDC provider") + + cmd.Flags().StringVar(&opts.OIDCRequestUrl, "oidc-request-url", "", "OIDC provider's request URL to fetch an OIDC token") + cmd.Flags().StringVar(&opts.DeepSourceHostEndpoint, "deepsource-host-endpoint", "https://app.deepsource.com", "DeepSource host endpoint where the app is running. Defaults to the cloud endpoint https://app.deepsource.com") + cmd.Flags().StringVar(&opts.OIDCProvider, "oidc-provider", "", "OIDC provider to use for authentication. Supported providers: github-actions") + // --skip-verify flag to skip SSL certificate verification while reporting test coverage data. cmd.Flags().BoolVar(&opts.SkipCertificateVerification, "skip-verify", false, "skip SSL certificate verification while sending the test coverage data") @@ -80,6 +93,9 @@ func (opts *ReportOptions) sanitize() { opts.Value = strings.TrimSpace(opts.Value) opts.ValueFile = strings.TrimSpace(opts.ValueFile) opts.DSN = strings.TrimSpace(os.Getenv("DEEPSOURCE_DSN")) + opts.OIDCRequestToken = strings.TrimSpace(opts.OIDCRequestToken) + opts.OIDCRequestUrl = strings.TrimSpace(opts.OIDCRequestUrl) + opts.DeepSourceHostEndpoint = strings.TrimSpace(opts.DeepSourceHostEndpoint) } func (opts *ReportOptions) validateKey() error { @@ -107,6 +123,15 @@ func (opts *ReportOptions) validateKey() error { func (opts *ReportOptions) Run() int { opts.sanitize() + if opts.UseOIDC { + dsn, err := utils.GetDSNFromOIDC(opts.OIDCRequestToken, opts.OIDCRequestUrl, opts.DeepSourceHostEndpoint, opts.OIDCProvider) + if err != nil { + fmt.Fprintln(os.Stderr, "DeepSource | Error | Failed to get DSN using OIDC:", err) + return 1 + } + opts.DSN = dsn + } + if opts.DSN == "" { fmt.Fprintln(os.Stderr, "DeepSource | Error | Environment variable DEEPSOURCE_DSN not set (or) is empty. You can find it under the repository settings page") return 1 diff --git a/utils/fetch_oidc_token.go b/utils/fetch_oidc_token.go new file mode 100644 index 00000000..b448cab6 --- /dev/null +++ b/utils/fetch_oidc_token.go @@ -0,0 +1,135 @@ +package utils + +import ( + "encoding/json" + "fmt" + "net/http" + "os" +) + +var ( + DEEPSOURCE_AUDIENCE = "DeepSource" + ALLOWED_PROVIDERS = map[string]bool{ + "github-actions": true, + } +) + +// FetchOIDCTokenFromProvider fetches the OIDC token from the OIDC token provider. +// It takes the request ID and the request URL as input and returns the OIDC token as a string. +func FetchOIDCTokenFromProvider(requestId, requestUrl string) (string, error) { + // requestid is the bearer token that needs to be sent to the request url + req, err := http.NewRequest("GET", requestUrl, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+requestId) + // set the expected audiences as the audience parameter + q := req.URL.Query() + q.Set("audience", DEEPSOURCE_AUDIENCE) + req.URL.RawQuery = q.Encode() + + // send the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + // check if the response is 200 + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch OIDC token: %s", resp.Status) + } + + // extract the token from the json response. The token is sent under the key `value` + // and the response is a json object + var tokenResponse struct { + Value string `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { + return "", err + } + // check if the token is empty + if tokenResponse.Value == "" { + return "", fmt.Errorf("failed to fetch OIDC token: empty token") + } + // return the token + return tokenResponse.Value, nil +} + +// ExchangeOIDCTokenForTempDSN exchanges the OIDC token for a temporary DSN. +// It sends the OIDC token to the respective DeepSource API endpoint and returns the temp DSN as string. +func ExchangeOIDCTokenForTempDSN(oidcToken, dsEndpoint, provider string) (string, error) { + apiEndpoint := fmt.Sprintf("%s/services/oidc/%s/", dsEndpoint, provider) + req, err := http.NewRequest("POST", apiEndpoint, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+oidcToken) + + type ExchangeResponse struct { + DSN string `json:"access_token"` + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to exchange OIDC token for DSN: %s", resp.Status) + } + var exchangeResponse ExchangeResponse + if err := json.NewDecoder(resp.Body).Decode(&exchangeResponse); err != nil { + return "", err + } + // check if the token is empty + if exchangeResponse.DSN == "" { + return "", fmt.Errorf("failed to exchange OIDC token for DSN: empty token") + } + // return the token + return exchangeResponse.DSN, nil +} + +func GetDSNFromOIDC(requestId, requestUrl, dsEndpoint, provider string) (string, error) { + // infer provider from environment variables. + // Github actions sets the GITHUB_ACTIONS environment variable to true by default. + if os.Getenv("GITHUB_ACTIONS") == "true" { + provider = "github-actions" + } + + if dsEndpoint == "" { + return "", fmt.Errorf("--deepsource-host-endpoint can not be empty") + } + + if provider == "" { + return "", fmt.Errorf("--oidc-provider can not be empty") + } + + isSupported := ALLOWED_PROVIDERS[provider] + if !isSupported { + return "", fmt.Errorf("provider %s is not supported for OIDC Token exchange (Supported Providers: %v)", provider, ALLOWED_PROVIDERS) + } + if requestId == "" || requestUrl == "" { + var foundIDToken, foundRequestURL bool + // try to fetch the token from the environment variables. + // skipcq: CRT-A0014 + switch provider { + case "github-actions": + requestId, foundIDToken = os.LookupEnv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + requestUrl, foundRequestURL = os.LookupEnv("ACTIONS_ID_TOKEN_REQUEST_URL") + if !(foundIDToken && foundRequestURL) { + errMsg := `failed to fetch "ACTIONS_ID_TOKEN_REQUEST_TOKEN" and "ACTIONS_ID_TOKEN_REQUEST_URL" from environment variables. Please make sure you are running this in a GitHub Actions environment with the required permissions. Or, use '--oidc-request-token' and '--oidc-request-url' flags to pass the token and request URL` + return "", fmt.Errorf("%s", errMsg) + } + } + } + oidcToken, err := FetchOIDCTokenFromProvider(requestId, requestUrl) + if err != nil { + return "", err + } + tempDSN, err := ExchangeOIDCTokenForTempDSN(oidcToken, dsEndpoint, provider) + if err != nil { + return "", err + } + return tempDSN, nil +} diff --git a/utils/fetch_oidc_token_test.go b/utils/fetch_oidc_token_test.go new file mode 100644 index 00000000..84a5b8ae --- /dev/null +++ b/utils/fetch_oidc_token_test.go @@ -0,0 +1,346 @@ +package utils + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +const ( + testRequestID = "test-request-id" + testOidcToken = "test-oidc-token" + testDSN = "test-dsn" + testProvider = "github-actions" + testDsEndpoint = "http://localhost:12345" // Mock dsEndpoint +) + +// TestFetchOIDCTokenFromProvider tests the FetchOIDCTokenFromProvider function +func TestFetchOIDCTokenFromProvider(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(struct { + Value string `json:"value"` + }{Value: testOidcToken}) + })) + defer server.Close() + + token, err := FetchOIDCTokenFromProvider(testRequestID, server.URL) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if token != testOidcToken { + t.Errorf("Expected token '%s', got '%s'", testOidcToken, token) + } + }) + + t.Run("http_new_request_error", func(t *testing.T) { + _, err := FetchOIDCTokenFromProvider(testRequestID, "://invalid-url") + if err == nil { + t.Fatal("Expected error for invalid URL, got nil") + } + }) + + t.Run("client_do_error", func(t *testing.T) { + // No server running at this URL + _, err := FetchOIDCTokenFromProvider(testRequestID, "http://localhost:9999/unreachable") + if err == nil { + t.Fatal("Expected error for unreachable server, got nil") + } + }) + + t.Run("non_200_status", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + _, err := FetchOIDCTokenFromProvider(testRequestID, server.URL) + if err == nil { + t.Fatal("Expected error for non-200 status, got nil") + } + expectedErrorMsg := "failed to fetch OIDC token: 404 Not Found" + if !strings.Contains(err.Error(), expectedErrorMsg) { + t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error()) + } + }) + + t.Run("json_decode_error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "not a json") + })) + defer server.Close() + + _, err := FetchOIDCTokenFromProvider(testRequestID, server.URL) + if err == nil { + t.Fatal("Expected error for invalid JSON response, got nil") + } + }) + + t.Run("empty_token_value", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(struct { + Value string `json:"value"` + }{Value: ""}) + })) + defer server.Close() + + _, err := FetchOIDCTokenFromProvider(testRequestID, server.URL) + if err == nil { + t.Fatal("Expected error for empty token value, got nil") + } + if !strings.Contains(err.Error(), "empty token") { + t.Errorf("Expected error message to contain 'empty token', got '%s'", err.Error()) + } + }) +} + +// TestExchangeOIDCTokenForTempDSN tests the ExchangeOIDCTokenForTempDSN function +func TestExchangeOIDCTokenForTempDSN(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(struct { + DSN string `json:"access_token"` + }{DSN: testDSN}) + })) + defer server.Close() + + dsn, err := ExchangeOIDCTokenForTempDSN(testOidcToken, server.URL, testProvider) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if dsn != testDSN { + t.Errorf("Expected DSN '%s', got '%s'", testDSN, dsn) + } + }) + + t.Run("http_new_request_error", func(t *testing.T) { + _, err := ExchangeOIDCTokenForTempDSN(testOidcToken, "://invalid-url", testProvider) + if err == nil { + t.Fatal("Expected error for invalid URL, got nil") + } + }) + + t.Run("client_do_error", func(t *testing.T) { + _, err := ExchangeOIDCTokenForTempDSN(testOidcToken, "http://localhost:9999/unreachable", testProvider) + if err == nil { + t.Fatal("Expected error for unreachable server, got nil") + } + }) + + t.Run("non_200_status", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + _, err := ExchangeOIDCTokenForTempDSN(testOidcToken, server.URL, testProvider) + if err == nil { + t.Fatal("Expected error for non-200 status, got nil") + } + expectedErrorMsg := "failed to exchange OIDC token for DSN: 403 Forbidden" + if !strings.Contains(err.Error(), expectedErrorMsg) { + t.Errorf("Expected error message to contain '%s', got '%s'", expectedErrorMsg, err.Error()) + } + }) + + t.Run("json_decode_error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "not a json") + })) + defer server.Close() + + _, err := ExchangeOIDCTokenForTempDSN(testOidcToken, server.URL, testProvider) + if err == nil { + t.Fatal("Expected error for invalid JSON response, got nil") + } + }) + + t.Run("empty_dsn_value", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(struct { + DSN string `json:"access_token"` + }{DSN: ""}) + })) + defer server.Close() + + _, err := ExchangeOIDCTokenForTempDSN(testOidcToken, server.URL, testProvider) + if err == nil { + t.Fatal("Expected error for empty DSN value, got nil") + } + if !strings.Contains(err.Error(), "empty token") { // The error message is "empty token" + t.Errorf("Expected error message to contain 'empty token', got '%s'", err.Error()) + } + }) +} + +// TestGetDSNFromOIDC tests the GetDSNFromOIDC function +// skipcq: GO-R1005 +func TestGetDSNFromOIDC(t *testing.T) { + // Mock servers for FetchOIDCTokenFromProvider and ExchangeOIDCTokenForTempDSN + var mockTokenServerURL, mockDSNServerURL string + + setupServers := func( + tokenHandler http.HandlerFunc, + dsnHandler http.HandlerFunc, + ) { + tokenServer := httptest.NewServer(tokenHandler) + mockTokenServerURL = tokenServer.URL + t.Cleanup(tokenServer.Close) + + dsnServer := httptest.NewServer(dsnHandler) + mockDSNServerURL = dsnServer.URL + t.Cleanup(dsnServer.Close) + } + + defaultTokenHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(struct { + Value string `json:"value"` + }{Value: testOidcToken}) + }) + + defaultDSNHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(struct { + DSN string `json:"access_token"` + }{DSN: testDSN}) + }) + + t.Run("success_with_params", func(t *testing.T) { + setupServers(defaultTokenHandler, defaultDSNHandler) + dsn, err := GetDSNFromOIDC(testRequestID, mockTokenServerURL, mockDSNServerURL, testProvider) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if dsn != testDSN { + t.Errorf("Expected DSN '%s', got '%s'", testDSN, dsn) + } + }) + + t.Run("success_with_github_actions_env_vars", func(t *testing.T) { + setupServers(defaultTokenHandler, defaultDSNHandler) + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", testRequestID) + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", mockTokenServerURL) + t.Cleanup(func() { + os.Unsetenv("GITHUB_ACTIONS") + os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_URL") + }) + + dsn, err := GetDSNFromOIDC("", "", mockDSNServerURL, "") // provider will be inferred + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if dsn != testDSN { + t.Errorf("Expected DSN '%s', got '%s'", testDSN, dsn) + } + }) + + t.Run("error_empty_ds_endpoint", func(t *testing.T) { + _, err := GetDSNFromOIDC(testRequestID, "url", "", testProvider) + if err == nil { + t.Fatal("Expected error for empty dsEndpoint, got nil") + } + if !strings.Contains(err.Error(), "--deepsource-host-endpoint can not be empty") { + t.Errorf("Unexpected error message: %s", err.Error()) + } + }) + + t.Run("error_empty_provider_no_env", func(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "false") // Ensure it's not inferred + t.Cleanup(func() { os.Unsetenv("GITHUB_ACTIONS") }) + _, err := GetDSNFromOIDC(testRequestID, "url", testDsEndpoint, "") + if err == nil { + t.Fatal("Expected error for empty provider, got nil") + } + if !strings.Contains(err.Error(), "--oidc-provider can not be empty") { + t.Errorf("Unexpected error message: %s", err.Error()) + } + }) + + t.Run("error_unsupported_provider", func(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "false") // Ensure GITHUB_ACTIONS env does not interfere + _, err := GetDSNFromOIDC(testRequestID, "url", testDsEndpoint, "unsupported") + if err == nil { + t.Fatal("Expected error for unsupported provider, got nil") + } + if !strings.Contains(err.Error(), "provider unsupported is not supported") { + t.Errorf("Expected error message to contain 'provider unsupported is not supported', got '%s'", err.Error()) + } + }) + + t.Run("error_github_actions_env_vars_missing_token", func(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + // ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "url") + t.Cleanup(func() { + os.Unsetenv("GITHUB_ACTIONS") + os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_URL") + }) + _, err := GetDSNFromOIDC("", "", testDsEndpoint, "") + if err == nil { + t.Fatal("Expected error for missing ACTIONS_ID_TOKEN_REQUEST_TOKEN, got nil") + } + if !strings.Contains(err.Error(), `failed to fetch "ACTIONS_ID_TOKEN_REQUEST_TOKEN"`) { + t.Errorf("Unexpected error message: %s", err.Error()) + } + }) + + t.Run("error_github_actions_env_vars_missing_url", func(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "token") + // ACTIONS_ID_TOKEN_REQUEST_URL is missing + t.Cleanup(func() { + os.Unsetenv("GITHUB_ACTIONS") + os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + }) + _, err := GetDSNFromOIDC("", "", testDsEndpoint, "") + if err == nil { + t.Fatal("Expected error for missing ACTIONS_ID_TOKEN_REQUEST_URL, got nil") + } + if !strings.Contains(err.Error(), `failed to fetch "ACTIONS_ID_TOKEN_REQUEST_TOKEN"`) { // Error message covers both + t.Errorf("Unexpected error message: %s", err.Error()) + } + }) + + t.Run("error_fetch_oidc_token_fails", func(t *testing.T) { + tokenHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) // Cause FetchOIDCTokenFromProvider to fail + }) + setupServers(tokenHandler, defaultDSNHandler) + + _, err := GetDSNFromOIDC(testRequestID, mockTokenServerURL, mockDSNServerURL, testProvider) + if err == nil { + t.Fatal("Expected error when FetchOIDCTokenFromProvider fails, got nil") + } + if !strings.Contains(err.Error(), "failed to fetch OIDC token") { + t.Errorf("Unexpected error message: %s", err.Error()) + } + }) + + t.Run("error_exchange_oidc_token_fails", func(t *testing.T) { + dsnHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) // Cause ExchangeOIDCTokenForTempDSN to fail + }) + setupServers(defaultTokenHandler, dsnHandler) + + _, err := GetDSNFromOIDC(testRequestID, mockTokenServerURL, mockDSNServerURL, testProvider) + if err == nil { + t.Fatal("Expected error when ExchangeOIDCTokenForTempDSN fails, got nil") + } + if !strings.Contains(err.Error(), "failed to exchange OIDC token for DSN") { + t.Errorf("Unexpected error message: %s", err.Error()) + } + }) +}