diff --git a/cli/azd/pkg/pipeline/github_provider.go b/cli/azd/pkg/pipeline/github_provider.go index b0c3f0cd91e..5a644bde03c 100644 --- a/cli/azd/pkg/pipeline/github_provider.go +++ b/cli/azd/pkg/pipeline/github_provider.go @@ -387,29 +387,49 @@ func (p *GitHubCiProvider) credentialOptions( } repoSlug := repoDetails.owner + "/" + repoDetails.repoName - credentialSafeName := credentialNameSanitizer.ReplaceAllString(repoSlug, "-") + credentialSafeName := credentialNameSanitizer.ReplaceAllString( + repoSlug, "-", + ) + + // Query OIDC subject claim customization and build subjects + subjects, err := p.resolveOIDCSubjects( + ctx, repoSlug, branches, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to resolve OIDC subjects: %w", err, + ) + } federatedCredentials := []*graphsdk.FederatedIdentityCredential{ { - Name: fmt.Sprintf("%s-pull_request", credentialSafeName), - Issuer: federatedIdentityIssuer, - Subject: fmt.Sprintf("repo:%s:pull_request", repoSlug), - Description: new("Created by Azure Developer CLI"), - Audiences: []string{federatedIdentityAudience}, + Name: fmt.Sprintf("%s-pull_request", credentialSafeName), + Issuer: federatedIdentityIssuer, + Subject: subjects.pullRequest, + Description: new( + "Created by Azure Developer CLI", + ), + Audiences: []string{federatedIdentityAudience}, }, } for _, branch := range branches { - safeBranchName := credentialNameSanitizer.ReplaceAllString(branch, "-") + safeBranchName := credentialNameSanitizer.ReplaceAllString( + branch, "-", + ) branchCredentials := &graphsdk.FederatedIdentityCredential{ - Name: fmt.Sprintf("%s-%s", credentialSafeName, safeBranchName), - Issuer: federatedIdentityIssuer, - Subject: fmt.Sprintf("repo:%s:ref:refs/heads/%s", repoSlug, branch), - Description: new("Created by Azure Developer CLI"), - Audiences: []string{federatedIdentityAudience}, + Name: fmt.Sprintf("%s-%s", credentialSafeName, safeBranchName), + Issuer: federatedIdentityIssuer, + Subject: subjects.branches[branch], + Description: new( + "Created by Azure Developer CLI", + ), + Audiences: []string{federatedIdentityAudience}, } - federatedCredentials = append(federatedCredentials, branchCredentials) + federatedCredentials = append( + federatedCredentials, branchCredentials, + ) } return &CredentialOptions{ @@ -426,6 +446,230 @@ func (p *GitHubCiProvider) credentialOptions( // *** ciProvider implementation ****** +// oidcSubjects holds the resolved OIDC subject strings for federated credentials. +type oidcSubjects struct { + pullRequest string + branches map[string]string +} + +// resolveOIDCSubjects queries the GitHub OIDC customization API, builds the +// auto-detected subject strings, and optionally prompts the user to confirm +// or override them. +func (p *GitHubCiProvider) resolveOIDCSubjects( + ctx context.Context, repoSlug string, branches []string, +) (*oidcSubjects, error) { + oidcConfig, repoInfo, err := p.detectOIDCConfig(ctx, repoSlug) + if err != nil { + return nil, err + } + + // Build auto-detected subjects + subjects, err := buildAllSubjects( + repoSlug, repoInfo, oidcConfig, branches, + ) + if err != nil { + // In non-interactive mode, fail hard on unsupported claim keys + if p.console.IsNoPromptMode() { + return nil, err + } + + // In interactive mode, warn and fall through to manual prompt + // with default-format pre-fills so the user can enter correct subjects + p.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: fmt.Sprintf( + "Unable to build OIDC subjects from detected config: %v."+ + " Falling back to default format for manual entry.", + err, + ), + }) + defaultConfig := &github.OIDCSubjectConfig{UseDefault: true} + subjects, err = buildAllSubjects( + repoSlug, nil, defaultConfig, branches, + ) + if err != nil { + return nil, err + } + } + + // In non-interactive mode, use detected subjects without prompting + if p.console.IsNoPromptMode() { + return subjects, nil + } + + return p.promptForSubjects(ctx, subjects) +} + +// detectOIDCConfig queries the GitHub OIDC customization API and fetches +// repo info if needed. Falls back to default format on API errors. +func (p *GitHubCiProvider) detectOIDCConfig( + ctx context.Context, repoSlug string, +) (*github.OIDCSubjectConfig, *github.RepoInfo, error) { + oidcConfig, err := p.ghCli.GetOIDCSubjectConfig(ctx, repoSlug) + if err != nil { + return nil, nil, fmt.Errorf( + "failed to query OIDC config for %s: %w", repoSlug, err, + ) + } + + var repoInfo *github.RepoInfo + if !oidcConfig.UseDefault && needsRepoInfo(oidcConfig) { + repoInfo, err = p.ghCli.GetRepoInfo(ctx, repoSlug) + if err != nil { + return nil, nil, fmt.Errorf( + "failed to get repository info for OIDC"+ + " subject construction: %w", err, + ) + } + } + return oidcConfig, repoInfo, nil +} + +// needsRepoInfo returns true if the OIDC config contains claim keys that +// require numeric repository/owner IDs from the GitHub API. +func needsRepoInfo(config *github.OIDCSubjectConfig) bool { + return slices.Contains(config.IncludeClaimKeys, "repository_owner_id") || + slices.Contains(config.IncludeClaimKeys, "repository_id") +} + +// buildAllSubjects constructs all OIDC subject strings from config. +func buildAllSubjects( + repoSlug string, + repoInfo *github.RepoInfo, + oidcConfig *github.OIDCSubjectConfig, + branches []string, +) (*oidcSubjects, error) { + prSubject, err := github.BuildOIDCSubject( + repoSlug, repoInfo, oidcConfig, "pull_request", + ) + if err != nil { + return nil, fmt.Errorf( + "failed to build OIDC subject for pull requests: %w", err, + ) + } + + branchSubjects := make(map[string]string, len(branches)) + for _, branch := range branches { + subject, err := github.BuildOIDCSubject( + repoSlug, repoInfo, oidcConfig, + fmt.Sprintf("ref:refs/heads/%s", branch), + ) + if err != nil { + return nil, fmt.Errorf( + "failed to build OIDC subject for branch %s: %w", + branch, err, + ) + } + branchSubjects[branch] = subject + } + + return &oidcSubjects{ + pullRequest: prSubject, + branches: branchSubjects, + }, nil +} + +const ( + oidcOptionUseDetected = "Use detected subjects (Recommended)" + oidcOptionCustom = "Enter custom subject manually" +) + +// promptForSubjects shows the detected OIDC subjects and lets the user +// use them or override them. +func (p *GitHubCiProvider) promptForSubjects( + ctx context.Context, + detected *oidcSubjects, +) (*oidcSubjects, error) { + // Display detected subjects + p.console.Message(ctx, "") + p.console.Message( + ctx, + "Detected OIDC subject format for federated credentials:", + ) + branchNames := slices.Sorted(maps.Keys(detected.branches)) + for _, branch := range branchNames { + p.console.Message( + ctx, + fmt.Sprintf(" • Branch %s: %s", branch, detected.branches[branch]), + ) + } + p.console.Message( + ctx, + fmt.Sprintf(" • Pull request: %s", detected.pullRequest), + ) + p.console.Message(ctx, "") + + options := []string{oidcOptionUseDetected, oidcOptionCustom} + selection, err := p.console.Select(ctx, input.ConsoleOptions{ + Message: "How would you like to configure federated" + + " identity credential subjects?", + Options: options, + }) + if err != nil { + return nil, fmt.Errorf("prompting for OIDC subject choice: %w", err) + } + + switch options[selection] { + case oidcOptionUseDetected: + return detected, nil + case oidcOptionCustom: + return p.promptCustomSubjects(ctx, detected) + } + + return detected, nil +} + +// promptCustomSubjects prompts the user to enter custom OIDC subjects, +// pre-filling with the auto-detected values as defaults. +func (p *GitHubCiProvider) promptCustomSubjects( + ctx context.Context, defaults *oidcSubjects, +) (*oidcSubjects, error) { + result := &oidcSubjects{ + branches: make(map[string]string, len(defaults.branches)), + } + + branchNames := slices.Sorted(maps.Keys(defaults.branches)) + for _, branch := range branchNames { + subject, err := p.console.Prompt(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf( + "Enter the OIDC subject for the '%s' branch credential:", + branch, + ), + DefaultValue: defaults.branches[branch], + }) + if err != nil { + return nil, fmt.Errorf( + "prompting for branch %s OIDC subject: %w", branch, err, + ) + } + subject = strings.TrimSpace(subject) + if subject == "" { + return nil, fmt.Errorf( + "OIDC subject for branch %s cannot be empty", branch, + ) + } + result.branches[branch] = subject + } + + prSubject, err := p.console.Prompt(ctx, input.ConsoleOptions{ + Message: "Enter the OIDC subject for the pull request credential:", + DefaultValue: defaults.pullRequest, + }) + if err != nil { + return nil, fmt.Errorf( + "prompting for pull request OIDC subject: %w", err, + ) + } + prSubject = strings.TrimSpace(prSubject) + if prSubject == "" { + return nil, fmt.Errorf( + "OIDC subject for pull request cannot be empty", + ) + } + result.pullRequest = prSubject + + return result, nil +} + // configureConnection set up GitHub account with Azure Credentials for // GitHub actions to use a service principal account to log in to Azure // and make changes on behalf of a user. diff --git a/cli/azd/pkg/pipeline/github_provider_test.go b/cli/azd/pkg/pipeline/github_provider_test.go index d8242bb0de5..0e549a183e0 100644 --- a/cli/azd/pkg/pipeline/github_provider_test.go +++ b/cli/azd/pkg/pipeline/github_provider_test.go @@ -138,3 +138,174 @@ func Test_credentialNameSanitizer(t *testing.T) { }) } } + +func Test_credentialOptions_withOIDCCustomSubject(t *testing.T) { + repoDetails := &gitRepositoryDetails{ + owner: "Azure-Samples", + repoName: "my-repo", + branch: "main", + } + repoSlug := "Azure-Samples/my-repo" + + // Helper to set up mock context with no-prompt mode + // (tests run non-interactively so prompts are skipped) + setupMock := func(t *testing.T) *mocks.MockContext { + t.Helper() + mc := mocks.NewMockContext(t.Context()) + setupGithubCliMocks(mc) + mc.Console.SetNoPromptMode(true) + return mc + } + + t.Run("default OIDC subjects when API returns use_default", func(t *testing.T) { + mockContext := setupMock(t) + + mockContext.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/"+repoSlug+ + "/actions/oidc/customization/sub") + }).Respond(exec.NewRunResult( + 0, + `{"use_default": true, "include_claim_keys": []}`, + "", + )) + + provider := createGitHubCiProvider(t, mockContext).(*GitHubCiProvider) + opts, err := provider.credentialOptions( + t.Context(), + repoDetails, + provisioning.Options{}, + AuthTypeFederated, + nil, + ) + require.NoError(t, err) + require.True(t, opts.EnableFederatedCredentials) + require.Len(t, opts.FederatedCredentialOptions, 2) + + prCred := opts.FederatedCredentialOptions[0] + require.Equal(t, + "repo:Azure-Samples/my-repo:pull_request", + prCred.Subject, + ) + + mainCred := opts.FederatedCredentialOptions[1] + require.Equal(t, + "repo:Azure-Samples/my-repo:ref:refs/heads/main", + mainCred.Subject, + ) + }) + + t.Run("custom OIDC subjects with ID-based claims", func(t *testing.T) { + mockContext := setupMock(t) + + mockContext.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/"+repoSlug+ + "/actions/oidc/customization/sub") + }).Respond(exec.NewRunResult( + 0, + `{"use_default": false, "include_claim_keys": `+ + `["repository_owner_id", "repository_id"]}`, + "", + )) + + mockContext.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/"+repoSlug) && + !strings.Contains(cmd, "oidc") + }).Respond(exec.NewRunResult( + 0, + `{"id": 599293758, "owner": {"id": 1844662}}`, + "", + )) + + provider := createGitHubCiProvider(t, mockContext).(*GitHubCiProvider) + opts, err := provider.credentialOptions( + t.Context(), + repoDetails, + provisioning.Options{}, + AuthTypeFederated, + nil, + ) + require.NoError(t, err) + require.True(t, opts.EnableFederatedCredentials) + + prCred := opts.FederatedCredentialOptions[0] + require.Equal(t, + "repository_owner_id:1844662:"+ + "repository_id:599293758:pull_request", + prCred.Subject, + ) + + mainCred := opts.FederatedCredentialOptions[1] + require.Equal(t, + "repository_owner_id:1844662:"+ + "repository_id:599293758:ref:refs/heads/main", + mainCred.Subject, + ) + }) + + t.Run("error on OIDC API failure", func(t *testing.T) { + mockContext := setupMock(t) + + mockContext.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "oidc/customization/sub") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "HTTP 403: Forbidden"), + fmt.Errorf("HTTP 403: Forbidden") + }) + + provider := createGitHubCiProvider(t, mockContext).(*GitHubCiProvider) + _, err := provider.credentialOptions( + t.Context(), + repoDetails, + provisioning.Options{}, + AuthTypeFederated, + nil, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to query") + }) + + t.Run("multiple branches with custom OIDC", func(t *testing.T) { + mockContext := setupMock(t) + + mockContext.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/Azure-Samples/my-repo"+ + "/actions/oidc/customization/sub") + }).Respond(exec.NewRunResult( + 0, + `{"use_default": false, "include_claim_keys": `+ + `["repository_owner_id", "repository_id"]}`, + "", + )) + + mockContext.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/Azure-Samples/my-repo") && + !strings.Contains(cmd, "oidc") + }).Respond(exec.NewRunResult( + 0, + `{"id": 599293758, "owner": {"id": 1844662}}`, + "", + )) + + devDetails := &gitRepositoryDetails{ + owner: "Azure-Samples", + repoName: "my-repo", + branch: "develop", + } + + provider := createGitHubCiProvider(t, mockContext).(*GitHubCiProvider) + opts, err := provider.credentialOptions( + t.Context(), + devDetails, + provisioning.Options{}, + AuthTypeFederated, + nil, + ) + require.NoError(t, err) + // PR + develop + main = 3 credentials + require.Len(t, opts.FederatedCredentialOptions, 3) + + for _, cred := range opts.FederatedCredentialOptions { + require.Contains(t, cred.Subject, "repository_owner_id:1844662") + } + }) +} diff --git a/cli/azd/pkg/pipeline/pipeline_coverage3_test.go b/cli/azd/pkg/pipeline/pipeline_coverage3_test.go index 0c523a5c57f..52e8d3b3d7d 100644 --- a/cli/azd/pkg/pipeline/pipeline_coverage3_test.go +++ b/cli/azd/pkg/pipeline/pipeline_coverage3_test.go @@ -1974,7 +1974,23 @@ func Test_parseAzDoRemote_nonStandardHost(t *testing.T) { func Test_GitHubCiProvider_credentialOptions_branchSpecialChars(t *testing.T) { t.Parallel() - provider := &GitHubCiProvider{} + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(true) + mockContext.CommandRunner.When( + func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "oidc/customization/sub") + }, + ).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "HTTP 404: Not Found"), + fmt.Errorf("HTTP 404: Not Found") + }) + + provider := &GitHubCiProvider{ + ghCli: github.NewGitHubCli( + mockContext.Console, mockContext.CommandRunner, + ), + console: mockContext.Console, + } opts, err := provider.credentialOptions(t.Context(), &gitRepositoryDetails{ diff --git a/cli/azd/pkg/pipeline/pipeline_helpers_test.go b/cli/azd/pkg/pipeline/pipeline_helpers_test.go index 087a5de4f6f..f963e90245c 100644 --- a/cli/azd/pkg/pipeline/pipeline_helpers_test.go +++ b/cli/azd/pkg/pipeline/pipeline_helpers_test.go @@ -5,17 +5,23 @@ package pipeline import ( "context" + "fmt" "os" "path/filepath" + "strings" "testing" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/entraid" + "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/graphsdk" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" - "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/azure/azure-dev/cli/azd/pkg/tools/github" + "github.com/azure/azure-dev/cli/azd/test/mocks" ) // ------------------------------------------------------------------ @@ -801,11 +807,34 @@ func Test_servicePrincipal(t *testing.T) { // ------------------------------------------------------------------ func Test_GitHubCiProvider_credentialOptions(t *testing.T) { - ctx := t.Context() - provider := &GitHubCiProvider{} + // Helper to create a provider with mocked OIDC API returning default + newProvider := func(t *testing.T) *GitHubCiProvider { + t.Helper() + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(true) + + // Mock OIDC API to return default config for any repo + mockContext.CommandRunner.When( + func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "oidc/customization/sub") + }, + ).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "HTTP 404: Not Found"), + fmt.Errorf("HTTP 404: Not Found") + }) + + return &GitHubCiProvider{ + ghCli: github.NewGitHubCli( + mockContext.Console, + mockContext.CommandRunner, + ), + console: mockContext.Console, + } + } t.Run("client-credentials auth", func(t *testing.T) { - opts, err := provider.credentialOptions(ctx, + provider := newProvider(t) + opts, err := provider.credentialOptions(t.Context(), &gitRepositoryDetails{ owner: "Azure", repoName: "azure-dev", @@ -820,7 +849,8 @@ func Test_GitHubCiProvider_credentialOptions(t *testing.T) { }) t.Run("federated auth creates credentials", func(t *testing.T) { - opts, err := provider.credentialOptions(ctx, + provider := newProvider(t) + opts, err := provider.credentialOptions(t.Context(), &gitRepositoryDetails{ owner: "Azure", repoName: "azure-dev", @@ -843,7 +873,8 @@ func Test_GitHubCiProvider_credentialOptions(t *testing.T) { t.Run( "federated on main branch - no duplicate", func(t *testing.T) { - opts, err := provider.credentialOptions(ctx, + provider := newProvider(t) + opts, err := provider.credentialOptions(t.Context(), &gitRepositoryDetails{ owner: "Azure", repoName: "azure-dev", @@ -859,7 +890,8 @@ func Test_GitHubCiProvider_credentialOptions(t *testing.T) { t.Run("empty auth type defaults to federated", func(t *testing.T) { - opts, err := provider.credentialOptions(ctx, + provider := newProvider(t) + opts, err := provider.credentialOptions(t.Context(), &gitRepositoryDetails{ owner: "Azure", repoName: "azure-dev", @@ -876,7 +908,8 @@ func Test_GitHubCiProvider_credentialOptions(t *testing.T) { t.Run( "unknown auth type returns empty options", func(t *testing.T) { - opts, err := provider.credentialOptions(ctx, + provider := newProvider(t) + opts, err := provider.credentialOptions(t.Context(), &gitRepositoryDetails{ owner: "Azure", repoName: "azure-dev", @@ -893,7 +926,8 @@ func Test_GitHubCiProvider_credentialOptions(t *testing.T) { t.Run( "federated credential names sanitized", func(t *testing.T) { - opts, err := provider.credentialOptions(ctx, + provider := newProvider(t) + opts, err := provider.credentialOptions(t.Context(), &gitRepositoryDetails{ owner: "my.org", repoName: "my.repo", diff --git a/cli/azd/pkg/tools/github/oidc.go b/cli/azd/pkg/tools/github/oidc.go new file mode 100644 index 00000000000..8c1f4c8ca2c --- /dev/null +++ b/cli/azd/pkg/tools/github/oidc.go @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package github + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// OIDCSubjectConfig represents the OIDC subject claim customization +// returned by the GitHub Actions OIDC customization API. +type OIDCSubjectConfig struct { + UseDefault bool `json:"use_default"` + IncludeClaimKeys []string `json:"include_claim_keys"` +} + +// RepoInfo holds GitHub API repository metadata needed for OIDC subject construction. +type RepoInfo struct { + ID int64 `json:"id"` + Owner struct { + ID int64 `json:"id"` + } `json:"owner"` +} + +// isGitHubNotFoundError returns true if the error indicates a GitHub HTTP 404 response. +func isGitHubNotFoundError(err error) bool { + if err == nil { + return false + } + + errText := strings.ToLower(err.Error()) + + // Only treat explicit HTTP 404 signals as GitHub "not found" responses. + // Avoid matching generic "not found" text, which can appear in unrelated + // failures such as missing executables, network issues, or permission errors. + return strings.Contains(errText, "http 404") || + strings.Contains(errText, "404 not found") +} + +// GetOIDCSubjectConfig queries the GitHub OIDC customization API for a repository. +// It checks the repo-level customization endpoint. If the repo returns a valid response +// (even with UseDefault=true), it is returned as-is. +// +// A repo-level 404 means the repository has not opted in to custom OIDC subjects. +// Per GitHub's docs, repos that haven't opted in receive default-format tokens +// regardless of any org-level template, so we return the default config directly +// without checking the org endpoint. +func (cli *Cli) GetOIDCSubjectConfig( + ctx context.Context, repoSlug string, +) (*OIDCSubjectConfig, error) { + runArgs := cli.newRunArgs( + "api", "/repos/"+repoSlug+"/actions/oidc/customization/sub", + ) + res, err := cli.run(ctx, runArgs) + if err == nil { + var config OIDCSubjectConfig + if jsonErr := json.Unmarshal([]byte(res.Stdout), &config); jsonErr != nil { + return nil, fmt.Errorf( + "failed to parse OIDC config for %s: %w", repoSlug, jsonErr, + ) + } + return &config, nil + } + + if !isGitHubNotFoundError(err) { + return nil, fmt.Errorf( + "failed to query repo-level OIDC config for %s: %w", repoSlug, err, + ) + } + + // Repo 404 = not opted in → default format. + return &OIDCSubjectConfig{UseDefault: true}, nil +} + +// GetRepoInfo queries the GitHub API for repository metadata (IDs) needed to +// construct OIDC subjects with custom claim keys like repository_owner_id. +func (cli *Cli) GetRepoInfo( + ctx context.Context, repoSlug string, +) (*RepoInfo, error) { + runArgs := cli.newRunArgs( + "api", "/repos/"+repoSlug, + "--jq", "{id: .id, owner: {id: .owner.id}}", + ) + res, err := cli.run(ctx, runArgs) + if err != nil { + return nil, fmt.Errorf( + "failed to get repository info for %s: %w", repoSlug, err, + ) + } + + var info RepoInfo + if err := json.Unmarshal([]byte(res.Stdout), &info); err != nil { + return nil, fmt.Errorf( + "failed to parse repository info for %s: %w", repoSlug, err, + ) + } + return &info, nil +} + +// BuildOIDCSubject constructs the correct OIDC subject claim string for a +// federated identity credential based on the OIDC customization config. +// +// This is a pure function — all needed data (repo info, config) must be +// pre-fetched. The suffix is the trailing part of the subject, e.g. +// "ref:refs/heads/main" or "pull_request". +// +// If oidcConfig is nil or UseDefault is true, the default GitHub format is used: +// +// repo:{owner}/{repo}:{suffix} +// +// For custom configs, claim keys are mapped to values and joined with the +// suffix. Unknown claim keys cause an error so the user knows azd needs +// updating. +func BuildOIDCSubject( + repoSlug string, + repoInfo *RepoInfo, + oidcConfig *OIDCSubjectConfig, + suffix string, +) (string, error) { + if oidcConfig == nil || oidcConfig.UseDefault { + return fmt.Sprintf("repo:%s:%s", repoSlug, suffix), nil + } + + if len(oidcConfig.IncludeClaimKeys) == 0 { + return "", fmt.Errorf( + "OIDC config for %s has use_default=false but no"+ + " claim keys specified", repoSlug, + ) + } + + var parts []string + for _, key := range oidcConfig.IncludeClaimKeys { + switch key { + case "repository_owner_id": + if repoInfo == nil { + return "", fmt.Errorf( + "OIDC config for %s includes claim key %q"+ + " but repository metadata is required", + repoSlug, key, + ) + } + parts = append(parts, + fmt.Sprintf("repository_owner_id:%d", repoInfo.Owner.ID), + ) + case "repository_id": + if repoInfo == nil { + return "", fmt.Errorf( + "OIDC config for %s includes claim key %q"+ + " but repository metadata is required", + repoSlug, key, + ) + } + parts = append(parts, + fmt.Sprintf("repository_id:%d", repoInfo.ID), + ) + case "repository_owner": + ownerParts := strings.SplitN(repoSlug, "/", 2) + if len(ownerParts) != 2 || ownerParts[0] == "" || ownerParts[1] == "" { + return "", fmt.Errorf( + "invalid repoSlug format: expected 'owner/repo', got %q", + repoSlug, + ) + } + parts = append(parts, + fmt.Sprintf("repository_owner:%s", ownerParts[0]), + ) + case "repository": + parts = append(parts, + fmt.Sprintf("repository:%s", repoSlug), + ) + default: + return "", fmt.Errorf( + "unsupported OIDC claim key %q in subject"+ + " template for %s — azd may need to be updated", + key, repoSlug, + ) + } + } + parts = append(parts, suffix) + return strings.Join(parts, ":"), nil +} diff --git a/cli/azd/pkg/tools/github/oidc_test.go b/cli/azd/pkg/tools/github/oidc_test.go new file mode 100644 index 00000000000..aba287817b1 --- /dev/null +++ b/cli/azd/pkg/tools/github/oidc_test.go @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package github + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/test/mocks" +) + +func TestBuildOIDCSubject(t *testing.T) { + repoSlug := "Azure-Samples/my-repo" + repoInfo := &RepoInfo{ + ID: 599293758, + } + repoInfo.Owner.ID = 1844662 + + tests := []struct { + name string + repoSlug string + repoInfo *RepoInfo + oidcConfig *OIDCSubjectConfig + suffix string + want string + wantErr string + }{ + { + name: "nil config uses default format", + repoSlug: repoSlug, + repoInfo: repoInfo, + oidcConfig: nil, + suffix: "ref:refs/heads/main", + want: "repo:Azure-Samples/my-repo:ref:refs/heads/main", + }, + { + name: "use_default true uses default format", + repoSlug: repoSlug, + repoInfo: repoInfo, + oidcConfig: &OIDCSubjectConfig{UseDefault: true}, + suffix: "pull_request", + want: "repo:Azure-Samples/my-repo:pull_request", + }, + { + name: "custom owner_id and repo_id claims", + repoSlug: repoSlug, + repoInfo: repoInfo, + oidcConfig: &OIDCSubjectConfig{ + UseDefault: false, + IncludeClaimKeys: []string{ + "repository_owner_id", + "repository_id", + }, + }, + suffix: "ref:refs/heads/main", + want: "repository_owner_id:1844662:" + + "repository_id:599293758:ref:refs/heads/main", + }, + { + name: "custom owner_id and repo_id for pull_request", + repoSlug: repoSlug, + repoInfo: repoInfo, + oidcConfig: &OIDCSubjectConfig{ + UseDefault: false, + IncludeClaimKeys: []string{ + "repository_owner_id", + "repository_id", + }, + }, + suffix: "pull_request", + want: "repository_owner_id:1844662:" + + "repository_id:599293758:pull_request", + }, + { + name: "custom with repository_owner and repository", + repoSlug: repoSlug, + repoInfo: repoInfo, + oidcConfig: &OIDCSubjectConfig{ + UseDefault: false, + IncludeClaimKeys: []string{ + "repository_owner", + "repository", + }, + }, + suffix: "ref:refs/heads/main", + want: "repository_owner:Azure-Samples:" + + "repository:Azure-Samples/my-repo:" + + "ref:refs/heads/main", + }, + { + name: "empty claim keys with use_default false errors", + repoSlug: repoSlug, + repoInfo: repoInfo, + oidcConfig: &OIDCSubjectConfig{ + UseDefault: false, + IncludeClaimKeys: []string{}, + }, + suffix: "ref:refs/heads/main", + wantErr: "no claim keys specified", + }, + { + name: "unknown claim key errors", + repoSlug: repoSlug, + repoInfo: repoInfo, + oidcConfig: &OIDCSubjectConfig{ + UseDefault: false, + IncludeClaimKeys: []string{ + "repository_owner_id", + "some_future_key", + }, + }, + suffix: "ref:refs/heads/main", + wantErr: "unsupported OIDC claim key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := BuildOIDCSubject( + tt.repoSlug, tt.repoInfo, tt.oidcConfig, tt.suffix, + ) + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestGetOIDCSubjectConfig(t *testing.T) { + repoSlug := "Azure-Samples/my-repo" + customConfig := OIDCSubjectConfig{ + UseDefault: false, + IncludeClaimKeys: []string{ + "repository_owner_id", "repository_id", + }, + } + customJSON, _ := json.Marshal(customConfig) + + defaultConfig := OIDCSubjectConfig{UseDefault: true} + defaultJSON, _ := json.Marshal(defaultConfig) + + tests := []struct { + name string + setup func(mockContext *mocks.MockContext) + wantConf *OIDCSubjectConfig + wantErr string + }{ + { + name: "repo-level returns custom config", + setup: func(mc *mocks.MockContext) { + mc.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/"+repoSlug+ + "/actions/oidc/customization/sub") + }).Respond(exec.NewRunResult( + 0, string(customJSON), "", + )) + }, + wantConf: &customConfig, + }, + { + name: "repo-level use_default true is returned as-is", + setup: func(mc *mocks.MockContext) { + mc.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/"+repoSlug+ + "/actions/oidc/customization/sub") + }).Respond(exec.NewRunResult( + 0, string(defaultJSON), "", + )) + }, + wantConf: &defaultConfig, + }, + { + name: "repo 404 returns default (no org fallback)", + setup: func(mc *mocks.MockContext) { + mc.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/"+repoSlug+ + "/actions/oidc/customization/sub") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "HTTP 404: Not Found"), + fmt.Errorf("HTTP 404: Not Found") + }) + }, + wantConf: &OIDCSubjectConfig{UseDefault: true}, + }, + { + name: "repo non-404 error is returned", + setup: func(mc *mocks.MockContext) { + mc.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/"+repoSlug+ + "/actions/oidc/customization/sub") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "HTTP 403: Forbidden"), + fmt.Errorf("HTTP 403: Forbidden") + }) + }, + wantErr: "failed to query repo-level OIDC config", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + tt.setup(mockContext) + + cli := NewGitHubCli( + mockContext.Console, mockContext.CommandRunner, + ) + // Set path so newRunArgs works + cli.path = "gh" + + config, err := cli.GetOIDCSubjectConfig( + t.Context(), repoSlug, + ) + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantConf, config) + }) + } +} + +func TestGetRepoInfo(t *testing.T) { + repoSlug := "Azure-Samples/my-repo" + + t.Run("success", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/"+repoSlug) + }).Respond(exec.NewRunResult( + 0, `{"id": 599293758, "owner": {"id": 1844662}}`, "", + )) + + cli := NewGitHubCli( + mockContext.Console, mockContext.CommandRunner, + ) + cli.path = "gh" + + info, err := cli.GetRepoInfo(t.Context(), repoSlug) + require.NoError(t, err) + require.Equal(t, int64(599293758), info.ID) + require.Equal(t, int64(1844662), info.Owner.ID) + }) + + t.Run("API error", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/"+repoSlug) + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.NewRunResult(1, "", "HTTP 403: Forbidden"), + fmt.Errorf("HTTP 403: Forbidden") + }) + + cli := NewGitHubCli( + mockContext.Console, mockContext.CommandRunner, + ) + cli.path = "gh" + + _, err := cli.GetRepoInfo(t.Context(), repoSlug) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to get repository info") + }) + + t.Run("malformed JSON", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.CommandRunner.When(func(args exec.RunArgs, cmd string) bool { + return strings.Contains(cmd, "/repos/"+repoSlug) + }).Respond(exec.NewRunResult( + 0, `{not valid json`, "", + )) + + cli := NewGitHubCli( + mockContext.Console, mockContext.CommandRunner, + ) + cli.path = "gh" + + _, err := cli.GetRepoInfo(t.Context(), repoSlug) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to parse repository info") + }) +}