Skip to content

fix: respect customized OIDC subject claims in azd pipeline config#7551

Open
charris-msft wants to merge 5 commits intoAzure:mainfrom
charris-msft:fix/oidc-custom-subject-claims
Open

fix: respect customized OIDC subject claims in azd pipeline config#7551
charris-msft wants to merge 5 commits intoAzure:mainfrom
charris-msft:fix/oidc-custom-subject-claims

Conversation

@charris-msft
Copy link
Copy Markdown

Description

Fixes #7374

When GitHub organizations customize their OIDC subject claim format (e.g. using repository_owner_id and repository_id instead of the default repo:owner/name format), azd pipeline config now queries the GitHub OIDC customization API to determine the actual subject format before creating federated identity credentials.

Problem

azd pipeline config always created federated credentials with the default subject format:

repo:{owner}/{repo}:ref:refs/heads/{branch}

But organizations like microsoft and Azure-Samples use customized OIDC subject claims, producing tokens with subjects like:

repository_owner_id:6154722:repository_id:1203140327:ref:refs/heads/main

This mismatch caused AADSTS700213: No matching federated identity record found errors.

Changes

  1. cli/azd/pkg/tools/github/github.go:

    • Added GetOIDCSubjectForRepo() to query the GitHub OIDC customization API (repo-level first, then org-level fallback)
    • Added BuildOIDCSubject() helper to construct the correct subject string based on the actual claim template
    • Supports known claim keys: repository_owner_id, repository_id, repository_owner, repository
  2. cli/azd/pkg/commands/pipeline/github_provider.go:

    • Updated applyFederatedCredentials() to query the OIDC config and use the correct subject format
    • Gracefully falls back to the default format if the OIDC API is unavailable

Testing

  • Verified the fix resolves the issue on a repo in the microsoft org that uses customized OIDC claims
  • Both modified packages compile successfully (go build)

postgres flexibleserver:
  - add authConfig, backup and maintenanceWindow

appservice:
  - add httpLoggingEnabled
  - add 'azd-service-name': 'web' to tags
When GitHub organizations customize their OIDC subject claim format
(e.g. using repository_owner_id and repository_id instead of the default
repo:owner/name format), azd pipeline config now queries the GitHub
OIDC customization API to determine the actual subject format before
creating federated identity credentials.

Previously, azd always used the default format:
  repo:{owner}/{repo}:ref:refs/heads/{branch}

But orgs like 'microsoft' use customized claims producing:
  repository_owner_id:{id}:repository_id:{id}:ref:refs/heads/{branch}

This mismatch caused AADSTS700213 errors during CI/CD pipeline runs.

The fix:
1. Adds GetOIDCSubjectForRepo() to GitHubCli to query the repo/org
   OIDC customization endpoint
2. Adds BuildOIDCSubject() helper to construct the correct subject
   string based on the actual claim template
3. Updates applyFederatedCredentials() to use the OIDC-aware subject
   construction

Fixes Azure#7374
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates azd pipeline config to account for GitHub org/repo-level OIDC subject claim customization when creating Entra federated identity credentials, avoiding subject mismatches that cause AADSTS700213 failures.

Changes:

  • Add GitHub OIDC customization lookup + subject-construction helper logic.
  • Update GitHub pipeline federated-credential creation to use the discovered subject format (with fallback).
  • Update shared Bicep templates (App Service + PostgreSQL flexible server) with additional configuration parameters.
Show a summary per file
File Description
templates/common/infra/bicep/core/host/appservice.bicep Adds an httpLoggingEnabled parameter and wires it into App Service siteConfig.
templates/common/infra/bicep/core/database/postgresql/flexibleserver.bicep Adds auth/HA/backup/maintenance parameters and configures them on the flexible server resource.
cli/azd/pkg/tools/github/github.go Introduces OIDC subject customization retrieval and subject construction logic.
cli/azd/pkg/commands/pipeline/github_provider.go Uses the new GitHub OIDC subject logic when creating federated identity credentials.

Copilot's findings

  • Files reviewed: 4/4 changed files
  • Comments generated: 6

Comment on lines +384 to +411
// GetOIDCSubjectForRepo queries the GitHub OIDC customization API for a repository.
// It first checks the repo-level customization, then falls back to the org-level customization.
// If no customization is found (or the API returns 404), it returns a config with UseDefault=true.
func (cli *ghCli) GetOIDCSubjectForRepo(ctx context.Context, repoSlug string) (*OIDCSubjectConfig, error) {
// Try repo-level first
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 && !config.UseDefault {
return &config, nil
}
}

// Fall back to org-level
parts := strings.SplitN(repoSlug, "/", 2)
if len(parts) == 2 {
orgRunArgs := cli.newRunArgs("api", "/orgs/"+parts[0]+"/actions/oidc/customization/sub")
orgRes, orgErr := cli.run(ctx, orgRunArgs)
if orgErr == nil {
var config OIDCSubjectConfig
if jsonErr := json.Unmarshal([]byte(orgRes.Stdout), &config); jsonErr == nil && !config.UseDefault {
return &config, nil
}
}
}

// Default: no customization
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetOIDCSubjectForRepo currently falls back to org-level customization whenever the repo-level response is either an error or use_default=true. If a repo explicitly sets use_default=true to override an org-level customization, this will incorrectly apply the org template. Consider returning the repo-level config whenever it unmarshals successfully (even when UseDefault is true), and only falling back to org-level on a confirmed 404/not-found response.

Suggested change
// GetOIDCSubjectForRepo queries the GitHub OIDC customization API for a repository.
// It first checks the repo-level customization, then falls back to the org-level customization.
// If no customization is found (or the API returns 404), it returns a config with UseDefault=true.
func (cli *ghCli) GetOIDCSubjectForRepo(ctx context.Context, repoSlug string) (*OIDCSubjectConfig, error) {
// Try repo-level first
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 && !config.UseDefault {
return &config, nil
}
}
// Fall back to org-level
parts := strings.SplitN(repoSlug, "/", 2)
if len(parts) == 2 {
orgRunArgs := cli.newRunArgs("api", "/orgs/"+parts[0]+"/actions/oidc/customization/sub")
orgRes, orgErr := cli.run(ctx, orgRunArgs)
if orgErr == nil {
var config OIDCSubjectConfig
if jsonErr := json.Unmarshal([]byte(orgRes.Stdout), &config); jsonErr == nil && !config.UseDefault {
return &config, nil
}
}
}
// Default: no customization
func isGitHubNotFoundError(err error) bool {
if err == nil {
return false
}
errText := strings.ToLower(err.Error())
return strings.Contains(errText, "404") || strings.Contains(errText, "not found")
}
// GetOIDCSubjectForRepo queries the GitHub OIDC customization API for a repository.
// It first checks the repo-level customization, then falls back to the org-level customization.
// If no customization is found (or the API returns 404), it returns a config with UseDefault=true.
func (cli *ghCli) GetOIDCSubjectForRepo(ctx context.Context, repoSlug string) (*OIDCSubjectConfig, error) {
// Try repo-level first.
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, jsonErr
}
return &config, nil
}
if !isGitHubNotFoundError(err) {
return nil, err
}
// Fall back to org-level only when the repo-level customization is not found.
parts := strings.SplitN(repoSlug, "/", 2)
if len(parts) == 2 {
orgRunArgs := cli.newRunArgs("api", "/orgs/"+parts[0]+"/actions/oidc/customization/sub")
orgRes, orgErr := cli.run(ctx, orgRunArgs)
if orgErr == nil {
var config OIDCSubjectConfig
if jsonErr := json.Unmarshal([]byte(orgRes.Stdout), &config); jsonErr != nil {
return nil, jsonErr
}
return &config, nil
}
if !isGitHubNotFoundError(orgErr) {
return nil, orgErr
}
}
// Default: no customization.

Copilot uses AI. Check for mistakes.
Comment on lines +686 to +688
prSubject, err := github.BuildOIDCSubject(ctx, ghCli, repoSlug, oidcConfig, "pull_request")
if err != nil {
return fmt.Errorf("failed to build OIDC subject for pull requests: %w", err)
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildOIDCSubject is invoked twice (for main + pull_request). When a repo uses custom claim keys, each call performs its own gh api /repos/{slug} request to fetch IDs, resulting in duplicate network/CLI calls during azd pipeline config. Consider fetching repo info once and reusing it (e.g., have GetOIDCSubjectForRepo also return the needed IDs, or add a helper that builds both subjects from a single repo lookup).

Suggested change
prSubject, err := github.BuildOIDCSubject(ctx, ghCli, repoSlug, oidcConfig, "pull_request")
if err != nil {
return fmt.Errorf("failed to build OIDC subject for pull requests: %w", err)
var prSubject string
switch {
case oidcConfig.UseDefault:
prSubject = fmt.Sprintf("repo:%s:pull_request", repoSlug)
case strings.Contains(mainSubject, ":context:ref:refs/heads/main"):
prSubject = strings.Replace(mainSubject, ":context:ref:refs/heads/main", ":context:pull_request", 1)
default:
// If the customized subject does not include the context claim, the subject
// is the same for main branch and pull request workflows.
prSubject = mainSubject

Copilot uses AI. Check for mistakes.
Comment on lines +336 to +348
func BuildOIDCSubject(
ctx context.Context, cli GitHubCli, repoSlug string, oidcConfig *OIDCSubjectConfig, suffix string,
) (string, error) {
if oidcConfig == nil || oidcConfig.UseDefault {
return fmt.Sprintf("repo:%s:%s", repoSlug, suffix), nil
}

// For custom claim templates, we need the repo and owner numeric IDs
ghCliImpl, ok := cli.(*ghCli)
if !ok {
// Fallback to default if we can't access the underlying CLI
return fmt.Sprintf("repo:%s:%s", repoSlug, suffix), nil
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildOIDCSubject accepts the GitHubCli interface but then type-asserts to *ghCli to make API calls. This undermines the interface abstraction (and will silently fall back to the default subject for any alternate implementation/mocks), making the behavior harder to test and reason about. Consider moving this logic onto *ghCli (method receiver) or extending the interface with the minimal repo-metadata method needed to build the subject.

Copilot uses AI. Check for mistakes.
Comment on lines +318 to +382
// OIDCSubjectConfig represents the OIDC subject claim customization for a GitHub repository or org.
type OIDCSubjectConfig struct {
UseDefault bool `json:"use_default"`
IncludeClaimKeys []string `json:"include_claim_keys"`
}

// gitHubRepoInfo holds GitHub API repo metadata needed for OIDC subject construction.
type gitHubRepoInfo struct {
ID int `json:"id"`
Owner struct {
ID int `json:"id"`
} `json:"owner"`
}

// BuildOIDCSubject constructs the correct OIDC subject claim for a federated identity credential.
// If the org/repo uses custom claim keys (e.g. repository_owner_id, repository_id), this function
// queries the GitHub API for the numeric IDs and builds the subject accordingly.
// The suffix is the trailing part of the subject, e.g. "ref:refs/heads/main" or "pull_request".
func BuildOIDCSubject(
ctx context.Context, cli GitHubCli, repoSlug string, oidcConfig *OIDCSubjectConfig, suffix string,
) (string, error) {
if oidcConfig == nil || oidcConfig.UseDefault {
return fmt.Sprintf("repo:%s:%s", repoSlug, suffix), nil
}

// For custom claim templates, we need the repo and owner numeric IDs
ghCliImpl, ok := cli.(*ghCli)
if !ok {
// Fallback to default if we can't access the underlying CLI
return fmt.Sprintf("repo:%s:%s", repoSlug, suffix), nil
}
runArgs := ghCliImpl.newRunArgs("api", "/repos/"+repoSlug, "--jq", "{id: .id, owner: {id: .owner.id}}")
res, err := ghCliImpl.run(ctx, runArgs)
if err != nil {
return "", fmt.Errorf("failed to get repository info for %s: %w", repoSlug, err)
}
var repoInfo gitHubRepoInfo
if err := json.Unmarshal([]byte(res.Stdout), &repoInfo); err != nil {
return "", fmt.Errorf("failed to parse repository info for %s: %w", repoSlug, err)
}

// Build subject from claim keys
// The claim keys define the parts before the context (ref/pull_request).
// Example: include_claim_keys=["repository_owner_id", "repository_id"] produces
// "repository_owner_id:123:repository_id:456:ref:refs/heads/main"
var parts []string
for _, key := range oidcConfig.IncludeClaimKeys {
switch key {
case "repository_owner_id":
parts = append(parts, fmt.Sprintf("repository_owner_id:%d", repoInfo.Owner.ID))
case "repository_id":
parts = append(parts, fmt.Sprintf("repository_id:%d", repoInfo.ID))
case "repository_owner":
owner := strings.SplitN(repoSlug, "/", 2)
parts = append(parts, fmt.Sprintf("repository_owner:%s", owner[0]))
case "repository":
parts = append(parts, fmt.Sprintf("repository:%s", repoSlug))
default:
// Unknown claim key — include it literally for forward compatibility
parts = append(parts, key)
}
}
parts = append(parts, suffix)
return strings.Join(parts, ":"), nil
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New OIDC subject logic (GetOIDCSubjectForRepo / BuildOIDCSubject) is not covered by unit tests in this package. Since cli/azd/pkg/tools/github already has github_test.go, consider adding tests that mock gh CLI output for: (1) default config, (2) repo-level custom include_claim_keys, (3) repo-level use_default=true overriding org-level custom, and (4) repo-info lookup used to build ID-based subjects.

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +46
authConfig: {
activeDirectoryAuth: activeDirectoryAuth
passwordAuth: (administratorLoginPassword == null) ? 'Disabled' : 'Enabled'
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

administratorLoginPassword is declared as a non-nullable string, so the (administratorLoginPassword == null) condition will never be true. As a result, passwordAuth will always be set to 'Enabled', which likely breaks the intent of supporting AAD-only auth. Either make the parameter nullable (e.g., string? with a null default) and validate combinations, or remove the null check and hardcode the intended behavior.

Copilot uses AI. Check for mistakes.
Comment on lines 33 to +50
@@ -46,6 +47,7 @@ resource appService 'Microsoft.Web/sites@2022-03-01' = {
linuxFxVersion: linuxFxVersion
alwaysOn: alwaysOn
ftpsState: ftpsState
httpLoggingEnabled: httpLoggingEnabled
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description and linked issue focus on GitHub OIDC subject claims, but this PR also changes shared Bicep templates (App Service + PostgreSQL flexible server). If these template changes are intentional, they should be called out in the PR description (and ideally justified/linked to an issue); otherwise, consider splitting them into a separate PR to keep the change set scoped.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@vhvb1989 vhvb1989 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you remove the changes from templates and try to add some tests?

I was thinking to show a confirmation prompt with the OIDC value to be used and a option for manual update for the user. What do you think? Pipeline config is a 100% interactive process and confirmations are very common along the way

Copy link
Copy Markdown
Member

@jongio jongio left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issues to address:

  • github.go:375 - unknown claim keys get appended without values, producing subjects that won't match GitHub's actual OIDC token
  • github.go:339 - missing guard for empty include_claim_keys when use_default is false

+1 on vhvb1989's feedback about removing the Bicep changes, adding tests, and adding a confirmation prompt.

Comment on lines +375 to +377
default:
// Unknown claim key — include it literally for forward compatibility
parts = append(parts, key)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Unknown claim keys get appended as bare names without values. parts = append(parts, key) produces somekey:ref:refs/heads/main instead of somekey:<value>:ref:refs/heads/main. When GitHub adds a new claim key, this silently creates a non-matching subject - the same AADSTS700213 error this PR is fixing.

Return an error so the user knows azd needs updating:

Suggested change
default:
// Unknown claim key — include it literally for forward compatibility
parts = append(parts, key)
default:
return "", fmt.Errorf("unsupported OIDC claim key %q in subject template for %s" +
" - azd may need to be updated", key, repoSlug)

Comment on lines +339 to +341
if oidcConfig == nil || oidcConfig.UseDefault {
return fmt.Sprintf("repo:%s:%s", repoSlug, suffix), nil
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] If the API returns use_default: false with an empty include_claim_keys, the subject ends up as just the suffix (e.g., ref:refs/heads/main) with no repo identifier. That'd match any repo's token - treat as error or fall back to default.

Suggested change
if oidcConfig == nil || oidcConfig.UseDefault {
return fmt.Sprintf("repo:%s:%s", repoSlug, suffix), nil
}
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)
}

Copy link
Copy Markdown
Contributor

@wbreza wbreza left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #7551

fix: respect customized OIDC subject claims in azd pipeline config by @charris-msft

Summary

Good fix for a real pain point (AADSTS700213 in orgs with custom claims). Two supplementary findings below — not duplicating @vhvb1989's or @jongio's existing feedback.

Prior Review Status

Reviewer Feedback Status
@vhvb1989 Remove Bicep changes, add tests, add confirmation prompt ⏳ Pending
@jongio Unknown claim keys, empty include_claim_keys guard ⏳ Pending

New Findings

🟠 1. [High] BuildOIDCSubject breaks the GitHubCli interface with type assertion

BuildOIDCSubject accepts GitHubCli interface but immediately does cli.(*ghCli) type assertion, silently falling back to default format for non-concrete implementations. This makes the custom claim code path untestable with mocks and makes the interface contract dishonest.

Suggested Fix: Add GetRepoInfo(ctx, repoSlug) (*gitHubRepoInfo, error) to the GitHubCli interface, or make BuildOIDCSubject a method on *ghCli.

🟡 2. [Medium] GetOIDCSubjectForRepo silently swallows auth/permission errors

Both repo-level and org-level API calls swallow ALL errors (not just 404). A 403 (no org permission) or 401 (expired token) produces UseDefault: true, silently creating wrong subjects for orgs that DO have custom claims. Consider distinguishing "not configured" (404) from "can't access" (401/403).

✅ What Looks Good

  • Clean fallback chain: repo-level → org-level → default
  • Correct claim key mapping for known claim types
  • Graceful degradation — OIDC lookup errors don't block pipeline config

+1 on @vhvb1989's feedback: remove the unrelated Bicep template changes and add tests.

Overall Assessment: Comment

Review performed with GitHub Copilot CLI

Copy link
Copy Markdown
Contributor

@wbreza wbreza left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-Up Code Review — PR #7551 (OIDC Security Focus)

fix: respect customized OIDC subject claims in azd pipeline config by @charris-msft

Summary

Follow-up to my earlier review with a deeper focus on the OIDC security and credential trust chain. Three new findings supplement existing feedback from @jongio, @vhvb1989, and my prior review.

New Findings

🟠 1. [High] No validation of GitHub API response IDs before embedding in subject (github.go ~line 355)

repoInfo.Owner.ID and repoInfo.ID are used directly in the OIDC subject without validation. If GitHub API returns 0, negative, or unexpected values, the credential subject becomes malformed (repository_owner_id:0:...), potentially matching unintended credentials.

Suggested Fix: if repoInfo.Owner.ID <= 0 || repoInfo.ID <= 0 { return "", fmt.Errorf("invalid repo IDs from GitHub API: owner=%d repo=%d", repoInfo.Owner.ID, repoInfo.ID) }

🟡 2. [Medium] repoSlug format not validated before parsing (github.go ~line 365, 409)

strings.SplitN(repoSlug, "/", 2) used in multiple places without validating owner/repo format. Malformed input (no /, empty string) silently produces wrong OIDC subjects.

Suggested Fix: Validate at function entry: if !strings.Contains(repoSlug, "/") { return "", fmt.Errorf("invalid repoSlug: %s", repoSlug) }

🟡 3. [Medium] GitHub OIDC API endpoints not documented with references (github.go ~line 388, 410)

The endpoints /repos/{repo}/actions/oidc/customization/sub and /orgs/{org}/actions/oidc/customization/sub are used without documentation references. Consider adding links to GitHub OIDC docs for endpoint stability and required permissions context.

Cumulative Findings (including prior review)

Priority Count
High 2 (type assertion + ID validation)
Medium 3 (error swallowing + repoSlug + API docs)
Low 1 (TOCTOU, theoretical)
Total 6

Overall Assessment: Comment — the ID validation (new finding #1) is the most actionable security concern. Combined with @jongio's unknown claim key finding, the BuildOIDCSubject function needs several hardening passes before merge.

Review performed with GitHub Copilot CLI

@charris-msft
Copy link
Copy Markdown
Author

@wbreza & @vhvb1989
I ran into this issue when prepping for my Reactor presentation and had to address it for my demo so I figured I'd submit the PR. However, I unfortunately don't have any time to follow through on any updates right now.

I really hate to throw what may be AI slop over the wall and run, but this was a big pain point in setting up my pipeline so I was hoping the PR might give you a running start.

@wbreza
Copy link
Copy Markdown
Contributor

wbreza commented Apr 8, 2026

@wbreza & @vhvb1989 I ran into this issue when prepping for my Reactor presentation and had to address it for my demo so I figured I'd submit the PR. However, I unfortunately don't have any time to follow through on any updates right now.

I really hate to throw what may be AI slop over the wall and run, but this was a big pain point in setting up my pipeline so I was hoping the PR might give you a running start.

Thanks @charris-msft - we'll get somebody to take it from here :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

azd pipeline config does not respect customized OIDC subject claims, causing federated credential mismatch

5 participants