fix: respect customized OIDC subject claims in azd pipeline config#7551
fix: respect customized OIDC subject claims in azd pipeline config#7551charris-msft wants to merge 5 commits intoAzure:mainfrom
Conversation
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
There was a problem hiding this comment.
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
| // 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 |
There was a problem hiding this comment.
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.
| // 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. |
| 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) |
There was a problem hiding this comment.
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).
| 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 |
| 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 | ||
| } |
There was a problem hiding this comment.
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.
| // 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 | ||
| } |
There was a problem hiding this comment.
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.
| authConfig: { | ||
| activeDirectoryAuth: activeDirectoryAuth | ||
| passwordAuth: (administratorLoginPassword == null) ? 'Disabled' : 'Enabled' | ||
| } |
There was a problem hiding this comment.
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.
| @@ -46,6 +47,7 @@ resource appService 'Microsoft.Web/sites@2022-03-01' = { | |||
| linuxFxVersion: linuxFxVersion | |||
| alwaysOn: alwaysOn | |||
| ftpsState: ftpsState | |||
| httpLoggingEnabled: httpLoggingEnabled | |||
There was a problem hiding this comment.
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.
vhvb1989
left a comment
There was a problem hiding this comment.
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
jongio
left a comment
There was a problem hiding this comment.
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.
| default: | ||
| // Unknown claim key — include it literally for forward compatibility | ||
| parts = append(parts, key) |
There was a problem hiding this comment.
[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:
| 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) |
| if oidcConfig == nil || oidcConfig.UseDefault { | ||
| return fmt.Sprintf("repo:%s:%s", repoSlug, suffix), nil | ||
| } |
There was a problem hiding this comment.
[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.
| 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) | |
| } |
wbreza
left a comment
There was a problem hiding this comment.
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
wbreza
left a comment
There was a problem hiding this comment.
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
|
@wbreza & @vhvb1989 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 :) |
Description
Fixes #7374
When GitHub organizations customize their OIDC subject claim format (e.g. using
repository_owner_idandrepository_idinstead of the defaultrepo:owner/nameformat),azd pipeline confignow queries the GitHub OIDC customization API to determine the actual subject format before creating federated identity credentials.Problem
azd pipeline configalways created federated credentials with the default subject format:But organizations like
microsoftandAzure-Samplesuse customized OIDC subject claims, producing tokens with subjects like:This mismatch caused
AADSTS700213: No matching federated identity record founderrors.Changes
cli/azd/pkg/tools/github/github.go:GetOIDCSubjectForRepo()to query the GitHub OIDC customization API (repo-level first, then org-level fallback)BuildOIDCSubject()helper to construct the correct subject string based on the actual claim templaterepository_owner_id,repository_id,repository_owner,repositorycli/azd/pkg/commands/pipeline/github_provider.go:applyFederatedCredentials()to query the OIDC config and use the correct subject formatTesting
microsoftorg that uses customized OIDC claimsgo build)