diff --git a/docs/azrepos-wif.md b/docs/azrepos-wif.md new file mode 100644 index 000000000..0e8f0147e --- /dev/null +++ b/docs/azrepos-wif.md @@ -0,0 +1,185 @@ +# Azure Workload Identity Federation + +Git Credential Manager supports [Workload Identity Federation][wif] for +authentication with Azure Repos. This document provides an overview of Workload +Identity Federation and how to use it with GCM. + +## Overview + +Workload Identity Federation allows a workload (such as a CI/CD pipeline, VM, or +container) to exchange a token from an external identity provider for a Microsoft +Entra ID access token — without needing to manage secrets like client secrets or +certificates. + +This is especially useful in scenarios where: + +- You want to avoid storing long-lived secrets. +- Your workload already has an identity token from another provider (e.g., GitHub + Actions OIDC, a Managed Identity, or a custom identity provider). +- You want to follow the principle of least privilege with short-lived, + automatically rotated credentials. + +You can read more about Workload Identity Federation in the +[Microsoft Entra documentation][wif]. + +## How it works + +When configured, GCM obtains a client assertion (a token from the external +identity provider) and exchanges it with Microsoft Entra ID for an access token +scoped to Azure DevOps. The exact mechanism for obtaining the client assertion +depends on the federation scenario you choose. + +## Scenarios + +GCM supports three federation scenarios: + +### Generic + +Use this scenario when you have a pre-obtained client assertion token from any +external identity provider. You provide the assertion directly and GCM exchanges +it for an access token. + +**Required settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Scenario|[`credential.azreposWorkloadFederation`][gcm-wif-config]|[`GCM_AZREPOS_WIF`][gcm-wif-env] +Client ID|[`credential.azreposWorkloadFederationClientId`][gcm-wif-clientid-config]|[`GCM_AZREPOS_WIF_CLIENTID`][gcm-wif-clientid-env] +Tenant ID|[`credential.azreposWorkloadFederationTenantId`][gcm-wif-tenantid-config]|[`GCM_AZREPOS_WIF_TENANTID`][gcm-wif-tenantid-env] +Assertion|[`credential.azreposWorkloadFederationAssertion`][gcm-wif-assertion-config]|[`GCM_AZREPOS_WIF_ASSERTION`][gcm-wif-assertion-env] + +**Optional settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Audience|[`credential.azreposWorkloadFederationAudience`][gcm-wif-audience-config]|[`GCM_AZREPOS_WIF_AUDIENCE`][gcm-wif-audience-env] + +#### Example + +```shell +git config --global credential.azreposWorkloadFederation generic +git config --global credential.azreposWorkloadFederationClientId "11111111-1111-1111-1111-111111111111" +git config --global credential.azreposWorkloadFederationTenantId "22222222-2222-2222-2222-222222222222" +git config --global credential.azreposWorkloadFederationAssertion "eyJhbGci..." +``` + +### Managed Identity + +Use this scenario when your workload runs on an Azure resource that has a +[Managed Identity][az-mi] assigned. GCM will first request a token from the +Managed Identity for the configured audience, then exchange that token for an +Azure DevOps access token. + +This is useful for Azure VMs, App Services, or other Azure resources that have a +Managed Identity but need to authenticate as a specific app registration with +a federated credential trust. + +**Required settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Scenario|[`credential.azreposWorkloadFederation`][gcm-wif-config]|[`GCM_AZREPOS_WIF`][gcm-wif-env] +Client ID|[`credential.azreposWorkloadFederationClientId`][gcm-wif-clientid-config]|[`GCM_AZREPOS_WIF_CLIENTID`][gcm-wif-clientid-env] +Tenant ID|[`credential.azreposWorkloadFederationTenantId`][gcm-wif-tenantid-config]|[`GCM_AZREPOS_WIF_TENANTID`][gcm-wif-tenantid-env] +Managed Identity|[`credential.azreposWorkloadFederationManagedIdentity`][gcm-wif-mi-config]|[`GCM_AZREPOS_WIF_MANAGEDIDENTITY`][gcm-wif-mi-env] + +**Optional settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Audience|[`credential.azreposWorkloadFederationAudience`][gcm-wif-audience-config]|[`GCM_AZREPOS_WIF_AUDIENCE`][gcm-wif-audience-env] + +The Managed Identity value accepts the same formats as +[`credential.azreposManagedIdentity`][gcm-mi-config]: + +Value|Description +-|- +`system`|System-Assigned Managed Identity +`[guid]`|User-Assigned Managed Identity with the specified client ID +`id://[guid]`|User-Assigned Managed Identity with the specified client ID +`resource://[guid]`|User-Assigned Managed Identity for the associated resource + +#### Example + +```shell +git config --global credential.azreposWorkloadFederation managedidentity +git config --global credential.azreposWorkloadFederationClientId "11111111-1111-1111-1111-111111111111" +git config --global credential.azreposWorkloadFederationTenantId "22222222-2222-2222-2222-222222222222" +git config --global credential.azreposWorkloadFederationManagedIdentity system +``` + +### GitHub Actions + +Use this scenario when your workload runs in a GitHub Actions workflow. GCM will +automatically obtain an OIDC token from the GitHub Actions runtime and exchange +it for an Azure DevOps access token. + +This scenario uses the `ACTIONS_ID_TOKEN_REQUEST_URL` and +`ACTIONS_ID_TOKEN_REQUEST_TOKEN` environment variables that GitHub Actions +automatically provides when a workflow has the `id-token: write` permission. + +**Required settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Scenario|[`credential.azreposWorkloadFederation`][gcm-wif-config]|[`GCM_AZREPOS_WIF`][gcm-wif-env] +Client ID|[`credential.azreposWorkloadFederationClientId`][gcm-wif-clientid-config]|[`GCM_AZREPOS_WIF_CLIENTID`][gcm-wif-clientid-env] +Tenant ID|[`credential.azreposWorkloadFederationTenantId`][gcm-wif-tenantid-config]|[`GCM_AZREPOS_WIF_TENANTID`][gcm-wif-tenantid-env] + +**Optional settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Audience|[`credential.azreposWorkloadFederationAudience`][gcm-wif-audience-config]|[`GCM_AZREPOS_WIF_AUDIENCE`][gcm-wif-audience-env] + +No additional GCM settings are required — the GitHub Actions OIDC environment +variables are read automatically. + +#### Prerequisites + +1. An app registration in Microsoft Entra ID with a federated credential + configured to trust your GitHub repository. +2. The app registration must have the necessary permissions to access Azure + DevOps. +3. Your GitHub Actions workflow must have the `id-token: write` permission. + +#### Example workflow + +```yaml +permissions: + id-token: write + contents: read + +steps: + - uses: actions/checkout@v4 + env: + GCM_AZREPOS_WIF: githubactions + GCM_AZREPOS_WIF_CLIENTID: "11111111-1111-1111-1111-111111111111" + GCM_AZREPOS_WIF_TENANTID: "22222222-2222-2222-2222-222222222222" +``` + +## Audience + +All scenarios accept an optional audience setting that controls the audience +claim in the federated token request. The default value is +`api://AzureADTokenExchange`, which is the standard audience for Microsoft Entra +ID workload identity federation. + +You only need to change this if your federated credential trust is configured +with a custom audience. + +[az-mi]: https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview +[wif]: https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation +[gcm-mi-config]: https://gh.io/gcm/config#credentialazreposmanagedidentity +[gcm-wif-config]: https://gh.io/gcm/config#credentialazreposworkloadfederation +[gcm-wif-clientid-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationclientid +[gcm-wif-tenantid-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationtenantid +[gcm-wif-audience-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationaudience +[gcm-wif-assertion-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationassertion +[gcm-wif-mi-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationmanagedidentity +[gcm-wif-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF +[gcm-wif-clientid-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_CLIENTID +[gcm-wif-tenantid-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_TENANTID +[gcm-wif-audience-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_AUDIENCE +[gcm-wif-assertion-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_ASSERTION +[gcm-wif-mi-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_MANAGEDIDENTITY diff --git a/docs/configuration.md b/docs/configuration.md index ba978ef30..af5d410f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -884,6 +884,138 @@ git config --global credential.azreposManagedIdentity "id://11111111-1111-1111-1 --- +### credential.azreposWorkloadFederation + +Use [Workload Identity Federation][wif] to authenticate with Azure Repos. + +The value specifies the federation scenario to use for obtaining a client +assertion to exchange for an access token. + +You must also set the following companion settings: + +- [credential.azreposWorkloadFederationClientId][credential-azrepos-wif-clientid] +- [credential.azreposWorkloadFederationTenantId][credential-azrepos-wif-tenantid] + +Depending on the scenario, additional settings may be required. + +Value|Description +-|- +`generic`|Use a user-supplied client assertion ([credential.azreposWorkloadFederationAssertion][credential-azrepos-wif-assertion]) +`managedidentity`|Use a [Managed Identity][managed-identity] to obtain the federated token ([credential.azreposWorkloadFederationManagedIdentity][credential-azrepos-wif-managedidentity]) +`githubactions`|Automatically obtain an OIDC token from GitHub Actions + +For more information about workload identity federation, see the +[conceptual documentation][azrepos-wif-doc] and the Azure DevOps +[documentation][azrepos-sp-mid]. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederation githubactions +``` + +**Also see: [GCM_AZREPOS_WIF][gcm-azrepos-wif]** + +--- + +### credential.azreposWorkloadFederationClientId + +The client ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[credential.azreposWorkloadFederation][credential-azrepos-wif]. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationClientId "11111111-1111-1111-1111-111111111111" +``` + +**Also see: [GCM_AZREPOS_WIF_CLIENTID][gcm-azrepos-wif-clientid]** + +--- + +### credential.azreposWorkloadFederationTenantId + +The tenant ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[credential.azreposWorkloadFederation][credential-azrepos-wif]. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationTenantId "22222222-2222-2222-2222-222222222222" +``` + +**Also see: [GCM_AZREPOS_WIF_TENANTID][gcm-azrepos-wif-tenantid]** + +--- + +### credential.azreposWorkloadFederationAudience + +The audience to use when requesting the federated token for +[Workload Identity Federation][wif] with +[credential.azreposWorkloadFederation][credential-azrepos-wif]. + +Defaults to `api://AzureADTokenExchange`. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationAudience "api://AzureADTokenExchange" +``` + +**Also see: [GCM_AZREPOS_WIF_AUDIENCE][gcm-azrepos-wif-audience]** + +--- + +### credential.azreposWorkloadFederationAssertion + +Specifies the client assertion token to use with the `generic` +[Workload Identity Federation][wif] scenario +([credential.azreposWorkloadFederation][credential-azrepos-wif]). + +This setting is required when `credential.azreposWorkloadFederation` is set to +`generic`. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationAssertion "eyJhbGci..." +``` + +**Also see: [GCM_AZREPOS_WIF_ASSERTION][gcm-azrepos-wif-assertion]** + +--- + +### credential.azreposWorkloadFederationManagedIdentity + +Specifies the [Managed Identity][managed-identity] to use to obtain a federated +token for the `managedidentity` [Workload Identity Federation][wif] scenario +([credential.azreposWorkloadFederation][credential-azrepos-wif]). + +This setting is required when `credential.azreposWorkloadFederation` is set to +`managedidentity`. + +The value accepts the same formats as +[credential.azreposManagedIdentity](#credentialazreposmanagedidentity). + +Value|Description +-|- +`system`|System-Assigned Managed Identity +`[guid]`|User-Assigned Managed Identity with the specified client ID +`id://[guid]`|User-Assigned Managed Identity with the specified client ID +`resource://[guid]`|User-Assigned Managed Identity for the associated resource + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationManagedIdentity system +``` + +**Also see: [GCM_AZREPOS_WIF_MANAGEDIDENTITY][gcm-azrepos-wif-managedidentity]** + +--- + ### credential.azreposServicePrincipal Specify the client and tenant IDs of a [service principal][service-principal] @@ -1048,6 +1180,12 @@ Defaults to disabled. [gcm-autodetect-timeout]: environment.md#GCM_AUTODETECT_TIMEOUT [gcm-azrepos-credentialtype]: environment.md#GCM_AZREPOS_CREDENTIALTYPE [gcm-azrepos-credentialmanagedidentity]: environment.md#GCM_AZREPOS_MANAGEDIDENTITY +[gcm-azrepos-wif]: environment.md#GCM_AZREPOS_WIF +[gcm-azrepos-wif-clientid]: environment.md#GCM_AZREPOS_WIF_CLIENTID +[gcm-azrepos-wif-tenantid]: environment.md#GCM_AZREPOS_WIF_TENANTID +[gcm-azrepos-wif-audience]: environment.md#GCM_AZREPOS_WIF_AUDIENCE +[gcm-azrepos-wif-assertion]: environment.md#GCM_AZREPOS_WIF_ASSERTION +[gcm-azrepos-wif-managedidentity]: environment.md#GCM_AZREPOS_WIF_MANAGEDIDENTITY [gcm-bitbucket-always-refresh-credentials]: environment.md#GCM_BITBUCKET_ALWAYS_REFRESH_CREDENTIALS [gcm-bitbucket-authmodes]: environment.md#GCM_BITBUCKET_AUTHMODES [gcm-credential-cache-options]: environment.md#GCM_CREDENTIAL_CACHE_OPTIONS @@ -1077,6 +1215,7 @@ Defaults to disabled. [autodetect]: autodetect.md [libsecret]: https://wiki.gnome.org/Projects/Libsecret [managed-identity]: https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview +[wif]: https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation [provider-migrate]: migration.md#gcm_authority [cache-options]: https://git-scm.com/docs/git-credential-cache#_options [pass]: https://www.passwordstore.org/ @@ -1090,6 +1229,13 @@ Defaults to disabled. [wam]: windows-broker.md [service-principal]: https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals [azrepos-sp-mid]: https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity +[azrepos-wif-doc]: azrepos-wif.md +[credential-azrepos-wif]: #credentialazreposworkloadfederation +[credential-azrepos-wif-clientid]: #credentialazreposworkloadfederationclientid +[credential-azrepos-wif-tenantid]: #credentialazreposworkloadfederationtenantid +[credential-azrepos-wif-audience]: #credentialazreposworkloadfederationaudience +[credential-azrepos-wif-assertion]: #credentialazreposworkloadfederationassertion +[credential-azrepos-wif-managedidentity]: #credentialazreposworkloadfederationmanagedidentity [credential-azrepos-sp]: #credentialazreposserviceprincipal [credential-azrepos-sp-secret]: #credentialazreposserviceprincipalsecret [credential-azrepos-sp-cert-thumbprint]: #credentialazreposserviceprincipalcertificatethumbprint diff --git a/docs/environment.md b/docs/environment.md index f321caa6c..44a50e4ee 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -991,6 +991,172 @@ export GCM_AZREPOS_MANAGEDIDENTITY="id://11111111-1111-1111-1111-111111111111" --- +### GCM_AZREPOS_WIF + +Use [Workload Identity Federation][wif] to authenticate with Azure Repos. + +The value specifies the federation scenario to use for obtaining a client +assertion to exchange for an access token. + +You must also set the following companion settings: + +- [GCM_AZREPOS_WIF_CLIENTID][gcm-azrepos-wif-clientid] +- [GCM_AZREPOS_WIF_TENANTID][gcm-azrepos-wif-tenantid] + +Depending on the scenario, additional settings may be required. + +Value|Description +-|- +`generic`|Use a user-supplied client assertion ([GCM_AZREPOS_WIF_ASSERTION][gcm-azrepos-wif-assertion]) +`managedidentity`|Use a [Managed Identity][managed-identity] to obtain the federated token ([GCM_AZREPOS_WIF_MANAGEDIDENTITY][gcm-azrepos-wif-managedidentity]) +`githubactions`|Automatically obtain an OIDC token from GitHub Actions + +For more information about workload identity federation, see the +[conceptual documentation][azrepos-wif-doc] and the Azure DevOps +[documentation][azrepos-sp-mid]. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF="githubactions" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF="githubactions" +``` + +**Also see: [credential.azreposWorkloadFederation][credential-azrepos-wif]** + +--- + +### GCM_AZREPOS_WIF_CLIENTID + +The client ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[GCM_AZREPOS_WIF][gcm-azrepos-wif]. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_CLIENTID="11111111-1111-1111-1111-111111111111" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_CLIENTID="11111111-1111-1111-1111-111111111111" +``` + +**Also see: [credential.azreposWorkloadFederationClientId][credential-azrepos-wif-clientid]** + +--- + +### GCM_AZREPOS_WIF_TENANTID + +The tenant ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[GCM_AZREPOS_WIF][gcm-azrepos-wif]. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_TENANTID="22222222-2222-2222-2222-222222222222" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_TENANTID="22222222-2222-2222-2222-222222222222" +``` + +**Also see: [credential.azreposWorkloadFederationTenantId][credential-azrepos-wif-tenantid]** + +--- + +### GCM_AZREPOS_WIF_AUDIENCE + +The audience to use when requesting the federated token for +[Workload Identity Federation][wif] with +[GCM_AZREPOS_WIF][gcm-azrepos-wif]. + +Defaults to `api://AzureADTokenExchange`. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_AUDIENCE="api://AzureADTokenExchange" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_AUDIENCE="api://AzureADTokenExchange" +``` + +**Also see: [credential.azreposWorkloadFederationAudience][credential-azrepos-wif-audience]** + +--- + +### GCM_AZREPOS_WIF_ASSERTION + +Specifies the client assertion token to use with the `generic` +[Workload Identity Federation][wif] scenario +([GCM_AZREPOS_WIF][gcm-azrepos-wif]). + +This setting is required when `GCM_AZREPOS_WIF` is set to `generic`. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_ASSERTION="eyJhbGci..." +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_ASSERTION="eyJhbGci..." +``` + +**Also see: [credential.azreposWorkloadFederationAssertion][credential-azrepos-wif-assertion]** + +--- + +### GCM_AZREPOS_WIF_MANAGEDIDENTITY + +Specifies the [Managed Identity][managed-identity] to use to obtain a federated +token for the `managedidentity` [Workload Identity Federation][wif] scenario +([GCM_AZREPOS_WIF][gcm-azrepos-wif]). + +This setting is required when `GCM_AZREPOS_WIF` is set to `managedidentity`. + +The value accepts the same formats as +[GCM_AZREPOS_MANAGEDIDENTITY](#gcm_azrepos_managedidentity). + +Value|Description +-|- +`system`|System-Assigned Managed Identity +`[guid]`|User-Assigned Managed Identity with the specified client ID +`id://[guid]`|User-Assigned Managed Identity with the specified client ID +`resource://[guid]`|User-Assigned Managed Identity for the associated resource + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_MANAGEDIDENTITY="system" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_MANAGEDIDENTITY="system" +``` + +**Also see: [credential.azreposWorkloadFederationManagedIdentity][credential-azrepos-wif-managedidentity]** + +--- + ### GCM_AZREPOS_SERVICE_PRINCIPAL Specify the client and tenant IDs of a [service principal][service-principal] @@ -1186,6 +1352,12 @@ Defaults to disabled. [credential-autodetecttimeout]: configuration.md#credentialautodetecttimeout [credential-azrepos-credential-type]: configuration.md#credentialazreposcredentialtype [credential-azrepos-managedidentity]: configuration.md#credentialazreposmanagedidentity +[credential-azrepos-wif]: configuration.md#credentialazreposworkloadfederation +[credential-azrepos-wif-clientid]: configuration.md#credentialazreposworkloadfederationclientid +[credential-azrepos-wif-tenantid]: configuration.md#credentialazreposworkloadfederationtenantid +[credential-azrepos-wif-audience]: configuration.md#credentialazreposworkloadfederationaudience +[credential-azrepos-wif-assertion]: configuration.md#credentialazreposworkloadfederationassertion +[credential-azrepos-wif-managedidentity]: configuration.md#credentialazreposworkloadfederationmanagedidentity [credential-bitbucketauthmodes]: configuration.md#credentialbitbucketAuthModes [credential-cacheoptions]: configuration.md#credentialcacheoptions [credential-credentialstore]: configuration.md#credentialcredentialstore @@ -1224,6 +1396,7 @@ Defaults to disabled. [network-http-proxy]: netconfig.md#http-proxy [libsecret]: https://wiki.gnome.org/Projects/Libsecret [managed-identity]: https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview +[wif]: https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation [migration-guide]: migration.md#gcm_authority [passwordstore]: https://www.passwordstore.org/ [trace2-normal-docs]: https://git-scm.com/docs/api-trace2#_the_normal_format_target @@ -1235,6 +1408,13 @@ Defaults to disabled. [windows-broker]: windows-broker.md [service-principal]: https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals [azrepos-sp-mid]: https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity +[azrepos-wif-doc]: azrepos-wif.md +[gcm-azrepos-wif]: #gcm_azrepos_wif +[gcm-azrepos-wif-clientid]: #gcm_azrepos_wif_clientid +[gcm-azrepos-wif-tenantid]: #gcm_azrepos_wif_tenantid +[gcm-azrepos-wif-audience]: #gcm_azrepos_wif_audience +[gcm-azrepos-wif-assertion]: #gcm_azrepos_wif_assertion +[gcm-azrepos-wif-managedidentity]: #gcm_azrepos_wif_managedidentity [gcm-azrepos-sp]: #gcm_azrepos_service_principal [gcm-azrepos-sp-secret]: #gcm_azrepos_sp_secret [gcm-azrepos-sp-cert-thumbprint]: #gcm_azrepos_sp_cert_thumbprint diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 5e2eca63c..5d65fa982 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -9,6 +9,7 @@ using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensions.Msal; using System.Text; +using System.Text.Json; using System.Threading; using GitCredentialManager.UI; using GitCredentialManager.UI.Controls; @@ -63,6 +64,14 @@ Task GetTokenForUserAsync(string authority, stri /// - "resource://{guid}" - Use the user-assigned managed identity with resource ID {guid}. /// Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource); + + /// + /// Acquire a token using workload federation. + /// + /// An object containing configuration workload federation. + /// Scopes to request. + /// Authentication result including access token. + Task GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes); } public class ServicePrincipalIdentity @@ -287,7 +296,8 @@ public async Task GetTokenForServicePrincipalAsy } } - public async Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource) + public async Task GetTokenForManagedIdentityAsync( + string managedIdentity, string resource) { var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); @@ -306,8 +316,88 @@ public async Task GetTokenForManagedIdentityAsyn { Context.Trace.WriteLine(mid == ManagedIdentityId.SystemAssigned ? "Failed to acquire token for system managed identity." - : $"Failed to acquire token for user managed identity '{managedIdentity:D}'."); + : $"Failed to acquire token for user managed identity '{managedIdentity}'."); + Context.Trace.WriteException(ex); + throw; + } + } + + public async Task GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes) + { + IConfidentialClientApplication app = await CreateConfidentialClientApplicationAsync(fedOpts); + + AuthenticationResult result = await app.AcquireTokenForClient(scopes) + .ExecuteAsync() + .ConfigureAwait(false); + + return new MsalResult(result); + } + + private async Task GetClientAssertion(MicrosoftWorkloadFederationOptions fedOpts, AssertionRequestOptions _) + { + switch (fedOpts.Scenario) + { + case MicrosoftWorkloadFederationScenario.Generic: + Context.Trace.WriteLine("Getting client assertion for generic workload federation scenario..."); + if (string.IsNullOrWhiteSpace(fedOpts.GenericClientAssertion)) + throw new InvalidOperationException( + "Client assertion must be provided for generic workload federation scenario."); + return fedOpts.GenericClientAssertion; + + case MicrosoftWorkloadFederationScenario.ManagedIdentity: + Context.Trace.WriteLine("Getting client assertion for managed identity workload federation scenario..."); + var miResult = await GetTokenForManagedIdentityAsync(fedOpts.ManagedIdentityId, fedOpts.Audience); + return miResult.AccessToken; + + case MicrosoftWorkloadFederationScenario.GitHubActions: + Context.Trace.WriteLine("Getting client assertion for GitHub Actions workload federation scenario..."); + return await GetGitHubOidcToken(fedOpts.GitHubTokenRequestUrl, fedOpts.Audience, fedOpts.GitHubTokenRequestToken); + + default: + throw new ArgumentOutOfRangeException(nameof(fedOpts.Scenario), fedOpts.Scenario, "Unsupported workload federation scenario."); + } + } + + private async Task GetGitHubOidcToken(Uri requestUri, string audience, string requestToken) + { + using HttpClient http = Context.HttpClientFactory.CreateClient(); + + UriBuilder ub = new UriBuilder(requestUri); + if (ub.Query.Length > 0) ub.Query += "&"; + ub.Query += $"audience={Uri.EscapeDataString(audience)}"; + + using var request = new HttpRequestMessage(HttpMethod.Get, ub.Uri); + request.AddBearerAuthenticationHeader(requestToken); + + Context.Trace.WriteLine($"Requesting GitHub OIDC token from '{request.RequestUri}'..."); + Context.Trace.WriteLineSecrets("OIDC request token: {0}", new[] { requestToken }); + using HttpResponseMessage response = await http.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + string error = await response.Content.ReadAsStringAsync(); + Context.Trace.WriteLine($"Failed to acquire GitHub OIDC token [{response.StatusCode:D} {response.StatusCode}]: {error}"); + response.EnsureSuccessStatusCode(); + } + + string json = await response.Content.ReadAsStringAsync(); + + try + { + using JsonDocument jsonDoc = JsonDocument.Parse(json); + if (!jsonDoc.RootElement.TryGetProperty("value", out JsonElement tokenElement)) + { + throw new InvalidOperationException( + "Invalid response from GitHub OIDC token endpoint: 'value' property not found."); + } + + return tokenElement.GetString() ?? + throw new InvalidOperationException( + "Invalid response from GitHub OIDC token endpoint: 'value' property is null."); + } + catch (Exception ex) + { Context.Trace.WriteException(ex); + Context.Trace.WriteLine($"OIDC token response: {json}"); throw; } } @@ -558,6 +648,24 @@ private async Task CreateConfidentialClientAppli return app; } + private async Task CreateConfidentialClientApplicationAsync( + MicrosoftWorkloadFederationOptions fedOpts) + { + var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); + + Context.Trace.WriteLine($"Creating federated confidential client application for {fedOpts.TenantId}/{fedOpts.ClientId}..."); + var appBuilder = ConfidentialClientApplicationBuilder.Create(fedOpts.ClientId) + .WithTenantId(fedOpts.TenantId) + .WithHttpClientFactory(httpFactoryAdaptor) + .WithClientAssertion(reqOpts => GetClientAssertion(fedOpts, reqOpts)); + + IConfidentialClientApplication app = appBuilder.Build(); + + await RegisterTokenCacheAsync(app.AppTokenCache, CreateAppTokenCacheProps, Context.Trace2); + + return app; + } + #endregion #region Helpers diff --git a/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs b/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs new file mode 100644 index 000000000..5511c0dee --- /dev/null +++ b/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs @@ -0,0 +1,77 @@ +using System; + +namespace GitCredentialManager.Authentication; + +public enum MicrosoftWorkloadFederationScenario +{ + /// + /// Federate via pre-computed client assertion. + /// + Generic, + + /// + /// Federate via an access token for an Entra ID Managed Identity. + /// + ManagedIdentity, + + /// + /// Federate via a GitHub Actions OIDC token. + /// + GitHubActions, +} + +public class MicrosoftWorkloadFederationOptions +{ + public const string DefaultAudience = Constants.DefaultWorkloadFederationAudience; + + private string _audience = DefaultAudience; + + /// + /// The workload federation scenario to use. + /// + public MicrosoftWorkloadFederationScenario Scenario { get; set; } + + /// + /// Tenant ID of the identity to request an access token for. + /// + public string TenantId { get; set; } + + /// + /// Client ID of the identity to request an access token for. + /// + public string ClientId { get; set; } + + /// + /// The audience to use when requesting a token. + /// + /// If this is null, the default audience will be used. + public string Audience + { + get => _audience; + set => _audience = value ?? DefaultAudience; + } + + /// + /// Generic assertion. + /// + /// Used with the federation scenario. + public string GenericClientAssertion { get; set; } + + /// + /// The managed identity to request a federated token for, to exchange for an access token. + /// + /// Used with the federation scenario. + public string ManagedIdentityId { get; set; } + + /// + /// GitHub Actions OIDC token request URI. + /// + /// Used with the federation scenario. + public Uri GitHubTokenRequestUrl { get; set; } + + /// + /// GitHub Actions OIDC token request token. + /// + /// Used with the federation scenario. + public string GitHubTokenRequestToken { get; set; } +} diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index e6625a6ea..6fecc2b38 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -31,6 +31,8 @@ public static class Constants /// public static readonly Guid MsaTransferTenantId = new("f8cdef31-a31e-4b4a-93e4-5f571e91255a"); + public const string DefaultWorkloadFederationAudience = "api://AzureADTokenExchange"; + public static class CredentialProtocol { public const string NtlmKey = "ntlm"; @@ -130,6 +132,9 @@ public static class EnvironmentVariables public const string GcmDevUseLegacyUiHelpers = "GCM_DEV_USELEGACYUIHELPERS"; public const string GcmGuiSoftwareRendering = "GCM_GUI_SOFTWARE_RENDERING"; public const string GcmAllowUnsafeRemotes = "GCM_ALLOW_UNSAFE_REMOTES"; + + public const string GitHubActionsTokenRequestUrl = "ACTIONS_ID_TOKEN_REQUEST_URL"; + public const string GitHubActionsTokenRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"; } public static class Http diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index bfd14d14f..e05db1646 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -605,6 +605,248 @@ public async Task AzureReposProvider_GetCredentialAsync_ManagedIdentity_ReturnsM AzureDevOpsConstants.AzureDevOpsResourceId), Times.Once); } + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_Generic_ReturnsFederationOptions() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "generic"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string assertion = "CLIENT-ASSERTION"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion] = assertion, + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.Generic && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience && + fed.GenericClientAssertion == assertion), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GenericFileAssertion_ReadsFromFile() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "generic"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string assertion = "CLIENT-ASSERTION-FROM-FILE"; + const string filePath = "/tmp/assertion-token.txt"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion] = $"file://{filePath}", + } + } + }; + + context.FileSystem.Files[filePath] = System.Text.Encoding.UTF8.GetBytes(assertion); + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.Generic && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience && + fed.GenericClientAssertion == assertion), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_MI_ReturnsFederationOptions() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "managedidentity"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string managedIdentity = "22222222-2222-2222-2222-222222222222"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationManagedIdentity] = managedIdentity, + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.ManagedIdentity && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience && + fed.ManagedIdentityId == managedIdentity), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GitHubActions_ReturnsFederationOptions() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "githubactions"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string ghRequestUrl = "https://token.actions.example.com/oidc/example?param=value"; + const string ghRequestToken = "OIDC-TOKEN"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [Constants.EnvironmentVariables.GitHubActionsTokenRequestUrl] = ghRequestUrl, + [Constants.EnvironmentVariables.GitHubActionsTokenRequestToken] = ghRequestToken, + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.GitHubActions && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.GitHubTokenRequestUrl == new Uri(ghRequestUrl) && + fed.GitHubTokenRequestToken == ghRequestToken && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + [Fact] public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_ReturnsSPCredential() { diff --git a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs index a282d4eff..f9ec8ce0f 100644 --- a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs +++ b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs @@ -46,6 +46,12 @@ public static class EnvironmentVariables public const string ServicePrincipalCertificateThumbprint = "GCM_AZREPOS_SP_CERT_THUMBPRINT"; public const string ServicePrincipalCertificateSendX5C = "GCM_AZREPOS_SP_CERT_SEND_X5C"; public const string ManagedIdentity = "GCM_AZREPOS_MANAGEDIDENTITY"; + public const string WorkloadFederation = "GCM_AZREPOS_WIF"; + public const string WorkloadFederationClientId = "GCM_AZREPOS_WIF_CLIENTID"; + public const string WorkloadFederationTenantId = "GCM_AZREPOS_WIF_TENANTID"; + public const string WorkloadFederationAudience = "GCM_AZREPOS_WIF_AUDIENCE"; + public const string WorkloadFederationAssertion = "GCM_AZREPOS_WIF_ASSERTION"; + public const string WorkloadFederationManagedIdentity = "GCM_AZREPOS_WIF_MANAGEDIDENTITY"; } public static class GitConfiguration @@ -62,6 +68,12 @@ public static class Credential public const string ServicePrincipalCertificateThumbprint = "azreposServicePrincipalCertificateThumbprint"; public const string ServicePrincipalCertificateSendX5C = "azreposServicePrincipalCertificateSendX5C"; public const string ManagedIdentity = "azreposManagedIdentity"; + public const string WorkloadFederation = "azreposWorkloadFederation"; + public const string WorkloadFederationClientId = "azreposWorkloadFederationClientId"; + public const string WorkloadFederationTenantId = "azreposWorkloadFederationTenantId"; + public const string WorkloadFederationAudience = "azreposWorkloadFederationAudience"; + public const string WorkloadFederationAssertion = "azreposWorkloadFederationAssertion"; + public const string WorkloadFederationManagedIdentity = "azreposWorkloadFederationManagedIdentity"; } } } diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index 72eb378eb..9a916a236 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -85,6 +85,15 @@ public async Task GetCredentialAsync(InputArguments input) ); } + if (UseWorkloadFederation(out MicrosoftWorkloadFederationOptions fedOpts)) + { + _context.Trace.WriteLine($"Getting Azure Access Token using WIF (scenario: {fedOpts.Scenario})..."); + var azureResult = await _msAuth.GetTokenUsingWorkloadFederationAsync(fedOpts, AzureDevOpsConstants.AzureDevOpsDefaultScopes); + return new GetCredentialResult( + new GitCredential(fedOpts.ClientId, azureResult.AccessToken) + ); + } + if (UseServicePrincipal(out ServicePrincipalIdentity sp)) { _context.Trace.WriteLine($"Getting Azure Access Token for service principal {sp.TenantId}/{sp.Id}..."); @@ -137,6 +146,10 @@ public Task StoreCredentialAsync(InputArguments input) { _context.Trace.WriteLine("Nothing to store for managed identity authentication."); } + else if (UseWorkloadFederation(out _)) + { + _context.Trace.WriteLine("Nothing to store for federated identity authentication."); + } else if (UseServicePrincipal(out _)) { _context.Trace.WriteLine("Nothing to store for service principal authentication."); @@ -172,6 +185,10 @@ public Task EraseCredentialAsync(InputArguments input) { _context.Trace.WriteLine("Nothing to erase for managed identity authentication."); } + else if (UseWorkloadFederation(out _)) + { + _context.Trace.WriteLine("Nothing to erase for federated identity authentication."); + } else if (UseServicePrincipal(out _)) { _context.Trace.WriteLine("Nothing to erase for service principal authentication."); @@ -588,6 +605,160 @@ private bool UseManagedIdentity(out string mid) !string.IsNullOrWhiteSpace(mid); } + private bool UseWorkloadFederation(out MicrosoftWorkloadFederationOptions fedOpts) + { + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederation, + out string wifStr)) + { + fedOpts = null; + return false; + } + + MicrosoftWorkloadFederationScenario scenario; + switch (wifStr.ToLowerInvariant()) + { + case "generic": + scenario = MicrosoftWorkloadFederationScenario.Generic; + break; + + case "mi": + case "managedidentity": + scenario = MicrosoftWorkloadFederationScenario.ManagedIdentity; + break; + + case "github": + case "githubactions": + scenario = MicrosoftWorkloadFederationScenario.GitHubActions; + break; + + default: // Unknown scenario value + fedOpts = null; + return false; + } + + bool hasClientId = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationClientId, + out string clientId); + + bool hasTenantId = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationTenantId, + out string tenantId); + + if (!hasClientId || !hasTenantId) + { + _context.Streams.Error.WriteLine("error: both client ID and tenant ID are required for workload federation"); + fedOpts = null; + return false; + } + + // Audience is optional - the default is "api://AzureADTokenExchange" + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAudience, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationAudience, + out string audience) || string.IsNullOrWhiteSpace(audience)) + { + audience = MicrosoftWorkloadFederationOptions.DefaultAudience; + } + + fedOpts = new MicrosoftWorkloadFederationOptions + { + Scenario = scenario, + ClientId = clientId, + TenantId = tenantId, + Audience = audience + }; + + switch (scenario) + { + case MicrosoftWorkloadFederationScenario.Generic: + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationAssertion, + out string assertion) || string.IsNullOrWhiteSpace(assertion)) + { + _context.Streams.Error.WriteLine("error: assertion is required for the generic workload federation scenario"); + fedOpts = null; + return false; + } + + // Check if this value points to a file containing the actual assertion (file://) + if (Uri.TryCreate(assertion, UriKind.Absolute, out Uri assertionUri) + && StringComparer.OrdinalIgnoreCase.Equals(assertionUri.Scheme, "file")) + { + string filePath = assertionUri.LocalPath; + if (!_context.FileSystem.FileExists(filePath)) + { + _context.Streams.Error.WriteLine($"error: assertion file not found: {filePath}"); + fedOpts = null; + return false; + } + + _context.Trace.WriteLine($"Reading workload federation assertion from file '{filePath}'..."); + assertion = _context.FileSystem.ReadAllText(filePath).Trim(); + if (string.IsNullOrWhiteSpace(assertion)) + { + _context.Streams.Error.WriteLine($"error: assertion file is empty: {filePath}"); + fedOpts = null; + return false; + } + } + + fedOpts.GenericClientAssertion = assertion; + break; + + case MicrosoftWorkloadFederationScenario.ManagedIdentity: + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationManagedIdentity, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationManagedIdentity, + out string managedIdentity) || string.IsNullOrWhiteSpace(managedIdentity)) + { + _context.Streams.Error.WriteLine("error: managed identity is required for the managed identity workload federation scenario"); + fedOpts = null; + return false; + } + + fedOpts.ManagedIdentityId = managedIdentity; + break; + + case MicrosoftWorkloadFederationScenario.GitHubActions: + if (!_context.Environment.Variables.TryGetValue( + Constants.EnvironmentVariables.GitHubActionsTokenRequestUrl, out string tokenRequestUrl) + || !Uri.TryCreate(tokenRequestUrl, UriKind.Absolute, out Uri tokenRequestUri)) + { + _context.Streams.Error.WriteLine( + "error: unable to get valid token request URL from environment variable for the GitHub Actions workload federation scenario"); + fedOpts = null; + return false; + } + + if (!_context.Environment.Variables.TryGetValue( + Constants.EnvironmentVariables.GitHubActionsTokenRequestToken, out string tokenRequestToken) + || string.IsNullOrWhiteSpace(tokenRequestToken)) + { + _context.Streams.Error.WriteLine( + "error: unable to get valid token request token from environment variable for the GitHub Actions workload federation scenario"); + fedOpts = null; + return false; + } + + fedOpts.GitHubTokenRequestUrl = tokenRequestUri; + fedOpts.GitHubTokenRequestToken = tokenRequestToken; + break; + } + + return true; + } + #endregion #region IConfigurationComponent