diff --git a/documentation/Connect-PnPOnline.md b/documentation/Connect-PnPOnline.md index cbde984cf..024d302ea 100644 --- a/documentation/Connect-PnPOnline.md +++ b/documentation/Connect-PnPOnline.md @@ -112,6 +112,11 @@ Connect-PnPOnline -OSLogin [-ReturnConnection] [-Url] [-PersistLogin] [ [-ClientId ] [-AzureEnvironment ] [-TenantAdminUrl ] [-ForceAuthentication] [-ValidateConnection] [-MicrosoftGraphEndPoint ] [-AzureADLoginEndPoint ] [-Connection ] ``` +### Federated Identity +```powershell +Connect-PnPOnline [-Url ] [-Tenant ] -FederatedIdentity [-AzureEnvironment ] [-TenantAdminUrl ] [-ValidateConnection] [-MicrosoftGraphEndPoint ] [-AzureADLoginEndPoint ] [-Connection ] +``` + ## DESCRIPTION Connects to a SharePoint site or another API and creates a context that is required for the other PnP Cmdlets. See https://pnp.github.io/powershell/articles/connecting.html for more information on the options to connect. @@ -289,6 +294,13 @@ Connect to SharePoint using Credentials (username and password) from Credential On Windows, this entry needs to be under "Generic Credentials". +### EXAMPLE 20 +```powershell +Connect-PnPOnline -Url "https://contoso.sharepoint.com" -ClientId 6c5c98c7-e05a-4a0f-bcfa-0cfc65aa1f28 -Tenant 'contoso.onmicrosoft.com' -FederatedIdentity +``` + +Connect to SharePoint/Microsoft Graph using federated identity credentials. + ## PARAMETERS ### -AccessToken @@ -876,6 +888,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -FederatedIdentity + +Connects using Federated Identity. For more information on this, you can visit [this link](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust?pivots=identity-wif-apps-methods-rest). + +```yaml +Type: SwitchParameter +Parameter Sets: Federated Identity +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ## RELATED LINKS [Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/src/Commands/Base/ConnectOnline.cs b/src/Commands/Base/ConnectOnline.cs index e6cd06d99..8a3df771c 100644 --- a/src/Commands/Base/ConnectOnline.cs +++ b/src/Commands/Base/ConnectOnline.cs @@ -37,6 +37,7 @@ public class ConnectOnline : BasePSCmdlet private const string ParameterSet_ENVIRONMENTVARIABLE = "Environment Variable"; private const string ParameterSet_AZUREAD_WORKLOAD_IDENTITY = "Azure AD Workload Identity"; private const string ParameterSet_OSLOGIN = "OS login"; + private const string ParameterSet_FEDERATEDIDENTITY = "Federated Identity"; [Parameter(Mandatory = false, ParameterSetName = ParameterSet_CREDENTIALS, ValueFromPipeline = true)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_ACSAPPONLY, ValueFromPipeline = true)] @@ -52,6 +53,7 @@ public class ConnectOnline : BasePSCmdlet [Parameter(Mandatory = false, ParameterSetName = ParameterSet_USERASSIGNEDMANAGEDIDENTITYBYAZURERESOURCEID)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_AZUREAD_WORKLOAD_IDENTITY)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_OSLOGIN, ValueFromPipeline = true)] + [Parameter(Mandatory = false, ParameterSetName = ParameterSet_FEDERATEDIDENTITY)] public SwitchParameter ReturnConnection; [Parameter(Mandatory = false, ParameterSetName = ParameterSet_CREDENTIALS, ValueFromPipeline = true)] @@ -68,6 +70,7 @@ public class ConnectOnline : BasePSCmdlet [Parameter(Mandatory = false, ParameterSetName = ParameterSet_USERASSIGNEDMANAGEDIDENTITYBYAZURERESOURCEID, ValueFromPipeline = true)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_AZUREAD_WORKLOAD_IDENTITY, ValueFromPipeline = true)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_OSLOGIN, ValueFromPipeline = true)] + [Parameter(Mandatory = false, ParameterSetName = ParameterSet_FEDERATEDIDENTITY, ValueFromPipeline = true)] public SwitchParameter ValidateConnection; [Parameter(Mandatory = true, Position = 0, ParameterSetName = ParameterSet_CREDENTIALS, ValueFromPipeline = true)] @@ -84,6 +87,7 @@ public class ConnectOnline : BasePSCmdlet [Parameter(Mandatory = true, Position = 0, ParameterSetName = ParameterSet_ENVIRONMENTVARIABLE, ValueFromPipeline = true)] [Parameter(Mandatory = false, Position = 0, ParameterSetName = ParameterSet_AZUREAD_WORKLOAD_IDENTITY, ValueFromPipeline = true)] [Parameter(Mandatory = true, Position = 0, ParameterSetName = ParameterSet_OSLOGIN, ValueFromPipeline = true)] + [Parameter(Mandatory = true, Position = 0, ParameterSetName = ParameterSet_FEDERATEDIDENTITY, ValueFromPipeline = true)] public string Url; [Parameter(Mandatory = false, ParameterSetName = ParameterSet_CREDENTIALS)] @@ -140,6 +144,7 @@ public class ConnectOnline : BasePSCmdlet [Parameter(Mandatory = false, ParameterSetName = ParameterSet_INTERACTIVE)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_DEVICELOGIN)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_OSLOGIN)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_FEDERATEDIDENTITY)] [Alias("ApplicationId")] public string ClientId; @@ -153,6 +158,7 @@ public class ConnectOnline : BasePSCmdlet [Parameter(Mandatory = false, ParameterSetName = ParameterSet_DEVICELOGIN)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_ENVIRONMENTVARIABLE)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_OSLOGIN)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_FEDERATEDIDENTITY)] public string Tenant; [Parameter(Mandatory = false, ParameterSetName = ParameterSet_APPONLYAADCERTIFICATE)] @@ -184,6 +190,7 @@ public class ConnectOnline : BasePSCmdlet [Parameter(Mandatory = false, ParameterSetName = ParameterSet_USERASSIGNEDMANAGEDIDENTITYBYCLIENTID)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_USERASSIGNEDMANAGEDIDENTITYBYPRINCIPALID)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_USERASSIGNEDMANAGEDIDENTITYBYAZURERESOURCEID)] + [Parameter(Mandatory = false, ParameterSetName = ParameterSet_FEDERATEDIDENTITY)] public Framework.AzureEnvironment AzureEnvironment = Framework.AzureEnvironment.Production; // [Parameter(Mandatory = true, ParameterSetName = ParameterSet_APPONLYCLIENTIDCLIENTSECRETAADDOMAIN)] @@ -204,6 +211,9 @@ public class ConnectOnline : BasePSCmdlet [Parameter(Mandatory = true, ParameterSetName = ParameterSet_USERASSIGNEDMANAGEDIDENTITYBYAZURERESOURCEID)] public SwitchParameter ManagedIdentity; + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_FEDERATEDIDENTITY)] + public SwitchParameter FederatedIdentity; + [Parameter(Mandatory = true, ParameterSetName = ParameterSet_USERASSIGNEDMANAGEDIDENTITYBYPRINCIPALID)] [Alias("UserAssignedManagedIdentityPrincipalId")] public string UserAssignedManagedIdentityObjectId; @@ -244,6 +254,7 @@ public class ConnectOnline : BasePSCmdlet [Parameter(Mandatory = false, ParameterSetName = ParameterSet_USERASSIGNEDMANAGEDIDENTITYBYPRINCIPALID)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_USERASSIGNEDMANAGEDIDENTITYBYAZURERESOURCEID)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_OSLOGIN)] + [Parameter(Mandatory = false, ParameterSetName = ParameterSet_FEDERATEDIDENTITY)] public string MicrosoftGraphEndPoint; [Parameter(Mandatory = false, ParameterSetName = ParameterSet_CREDENTIALS)] @@ -259,6 +270,7 @@ public class ConnectOnline : BasePSCmdlet [Parameter(Mandatory = false, ParameterSetName = ParameterSet_USERASSIGNEDMANAGEDIDENTITYBYPRINCIPALID)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_USERASSIGNEDMANAGEDIDENTITYBYAZURERESOURCEID)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_OSLOGIN)] + [Parameter(Mandatory = false, ParameterSetName = ParameterSet_FEDERATEDIDENTITY)] public string AzureADLoginEndPoint; [Parameter(Mandatory = true, ParameterSetName = ParameterSet_AZUREAD_WORKLOAD_IDENTITY)] @@ -376,6 +388,9 @@ protected void Connect(ref CancellationToken cancellationToken) case ParameterSet_OSLOGIN: newConnection = ConnectWithOSLogin(); break; + case ParameterSet_FEDERATEDIDENTITY: + newConnection = ConnectFederatedIdentity(); + break; } // Ensure a connection instance has been created by now @@ -916,6 +931,12 @@ private PnPConnection ConnectWithOSLogin() return PnPConnection.CreateWithInteractiveLogin(new Uri(Url.ToLower()), ClientId, TenantAdminUrl, AzureEnvironment, cancellationTokenSource, ForceAuthentication, Tenant, true, PersistLogin, Host); } + private PnPConnection ConnectFederatedIdentity() + { + LogDebug("Connecting using Federated Identity Credentials"); + + return PnPConnection.CreateWithFederatedIdentity(Url, TenantAdminUrl, ClientId, Tenant); + } #endregion #region Helper methods diff --git a/src/Commands/Base/PnPConnection.cs b/src/Commands/Base/PnPConnection.cs index 071c64e52..66889316e 100644 --- a/src/Commands/Base/PnPConnection.cs +++ b/src/Commands/Base/PnPConnection.cs @@ -378,27 +378,6 @@ internal static PnPConnection CreateWithCert(Uri url, string clientId, string te /// Instantiated PnPConnection internal static PnPConnection CreateWithManagedIdentity(string url, string tenantAdminUrl, string userAssignedManagedIdentityObjectId = null, string userAssignedManagedIdentityClientId = null, string userAssignedManagedIdentityAzureResourceId = null) { - var endPoint = Environment.GetEnvironmentVariable("IDENTITY_ENDPOINT"); - PnP.Framework.Diagnostics.Log.Debug("PnPConnection", $"Using identity endpoint: {endPoint}"); - //cmdlet.LogDebug($"Using identity endpoint: {endPoint}"); - - var identityHeader = Environment.GetEnvironmentVariable("IDENTITY_HEADER"); - PnP.Framework.Diagnostics.Log.Debug("PnPConnection", $"Using identity header: {identityHeader}"); - //cmdlet.LogDebug($"Using identity header: {identityHeader}"); - - if (string.IsNullOrEmpty(endPoint)) - { - endPoint = Environment.GetEnvironmentVariable("MSI_ENDPOINT"); - identityHeader = Environment.GetEnvironmentVariable("MSI_SECRET"); - } - if (string.IsNullOrEmpty(endPoint)) - { - // additional fallback - // using well-known endpoint for Instance Metadata Service, useful in Azure VM scenario. - // https://learn.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http - endPoint = "http://169.254.169.254/metadata/identity/oauth2/token"; - } - // Define the type of Managed Identity that will be used ManagedIdentityType managedIdentityType = ManagedIdentityType.SystemAssigned; string managedIdentityUserAssignedIdentifier = null; @@ -426,32 +405,35 @@ internal static PnPConnection CreateWithManagedIdentity(string url, string tenan } // Set up the AuthenticationManager in PnP Framework to use a Managed Identity context - using var authManager = new Framework.AuthenticationManager(endPoint, identityHeader, managedIdentityType, managedIdentityUserAssignedIdentifier); - PnPClientContext context = null; - ConnectionType connectionType = ConnectionType.O365; - if (url != null) + using (var authManager = Framework.AuthenticationManager.CreateWithManagedIdentity(null, null, managedIdentityType, managedIdentityUserAssignedIdentifier)) { - context = PnPClientContext.ConvertFrom(authManager.GetContext(url.ToString())); - context.ApplicationName = Resources.ApplicationName; - context.DisableReturnValueCache = true; - context.ExecutingWebRequest += (sender, e) => - { - e.WebRequestExecutor.WebRequest.UserAgent = $"NONISV|SharePointPnP|PnPPS/{((AssemblyFileVersionAttribute)Assembly.GetExecutingAssembly().GetCustomAttribute(typeof(AssemblyFileVersionAttribute))).Version} ({System.Environment.OSVersion.VersionString})"; - }; - if (IsTenantAdminSite(context)) + PnPClientContext context = null; + ConnectionType connectionType = ConnectionType.O365; + if (url != null) { - connectionType = ConnectionType.TenantAdmin; + context = PnPClientContext.ConvertFrom(authManager.GetContext(url.ToString())); + context.ApplicationName = Resources.ApplicationName; + context.DisableReturnValueCache = true; + context.ExecutingWebRequest += (sender, e) => + { + e.WebRequestExecutor.WebRequest.UserAgent = $"NONISV|SharePointPnP|PnPPS/{((AssemblyFileVersionAttribute)Assembly.GetExecutingAssembly().GetCustomAttribute(typeof(AssemblyFileVersionAttribute))).Version} ({System.Environment.OSVersion.VersionString})"; + }; + if (IsTenantAdminSite(context)) + { + connectionType = ConnectionType.TenantAdmin; + } } - } - // Set up PnP PowerShell to use a Managed Identity - var connection = new PnPConnection(context, connectionType, null, url?.ToString(), tenantAdminUrl, PnPPSVersionTag, InitializationType.ManagedIdentity) - { - UserAssignedManagedIdentityObjectId = userAssignedManagedIdentityObjectId, - UserAssignedManagedIdentityClientId = userAssignedManagedIdentityClientId, - UserAssignedManagedIdentityAzureResourceId = userAssignedManagedIdentityAzureResourceId - }; - return connection; + // Set up PnP PowerShell to use a Managed Identity + var connection = new PnPConnection(context, connectionType, null, url?.ToString(), tenantAdminUrl, PnPPSVersionTag, InitializationType.ManagedIdentity) + { + UserAssignedManagedIdentityObjectId = userAssignedManagedIdentityObjectId, + UserAssignedManagedIdentityClientId = userAssignedManagedIdentityClientId, + UserAssignedManagedIdentityAzureResourceId = userAssignedManagedIdentityAzureResourceId, + ConnectionMethod = ConnectionMethod.ManagedIdentity, + }; + return connection; + } } internal static PnPConnection CreateWithCredentials(Cmdlet cmdlet, Uri url, PSCredential credentials, bool currentCredentials, string tenantAdminUrl, bool persistLogin, System.Management.Automation.Host.PSHost host, AzureEnvironment azureEnvironment = AzureEnvironment.Production, string clientId = null, string redirectUrl = null, bool onPrem = false, InitializationType initializationType = InitializationType.Credentials) @@ -585,7 +567,7 @@ internal static PnPConnection CreateWithInteractiveLogin(Uri uri, string clientI WriteCacheEnabledMessage(host); } - var htmlMessageSuccess ="PnP PowerShell - Sign InPnP PowerShell
You are signed in now and can close this page.
"; + var htmlMessageSuccess = "PnP PowerShell - Sign InPnP PowerShell
You are signed in now and can close this page.
"; var htmlMessageFailure = "PnP PowerShell - Sign InPnP PowerShell
An error occured while signing in: {0}
"; PnP.Framework.AuthenticationManager authManager = null; @@ -597,7 +579,7 @@ internal static PnPConnection CreateWithInteractiveLogin(Uri uri, string clientI else { authManager = PnP.Framework.AuthenticationManager.CreateWithInteractiveWebBrowserLogin(clientId, (url, port) => - { + { BrowserHelper.OpenBrowserForInteractiveLogin(url, port, cancellationTokenSource); }, tenant, @@ -675,6 +657,56 @@ internal static PnPConnection CreateWithAzureADWorkloadIdentity(string url, stri return connection; } } + + /// + /// Creates a PnPConnection using a Federated Identity + /// + /// Url to the SharePoint Online site to connect to + /// Url to the SharePoint Online Admin Center site to connect to + /// The Client ID of the Federated Identity application + /// The Tenant ID of the Federated Identity application + /// Instantiated PnPConnection + /// + /// This method is used to create a PnPConnection using a Federated Identity, which allows for authentication without the need for a client secret. + /// + internal static PnPConnection CreateWithFederatedIdentity(string url, string tenantAdminUrl, string appClientId, string tenantId) + { + string defaultResource = "https://graph.microsoft.com/.default"; + if (url != null) + { + var resourceUri = new Uri(url); + defaultResource = $"{resourceUri.Scheme}://{resourceUri.Authority}/.default"; + } + + PnP.Framework.Diagnostics.Log.Debug("PnPConnection", "Acquiring token for resource " + defaultResource); + var accessToken = TokenHandler.GetFederatedIdentityTokenAsync(appClientId, tenantId, defaultResource).GetAwaiter().GetResult(); + + // Set up the AuthenticationManager in PnP Framework to use a Federated Identity context + using (var authManager = new PnP.Framework.AuthenticationManager(new System.Net.NetworkCredential("", accessToken).SecurePassword)) + { + PnPClientContext context = null; + ConnectionType connectionType = ConnectionType.O365; + if (url != null) + { + context = PnPClientContext.ConvertFrom(authManager.GetContext(url.ToString())); + context.ApplicationName = Resources.ApplicationName; + context.DisableReturnValueCache = true; + context.ExecutingWebRequest += (sender, e) => + { + e.WebRequestExecutor.WebRequest.UserAgent = $"NONISV|SharePointPnP|PnPPS/{((AssemblyFileVersionAttribute)Assembly.GetExecutingAssembly().GetCustomAttribute(typeof(AssemblyFileVersionAttribute))).Version} ({System.Environment.OSVersion.VersionString})"; + }; + if (IsTenantAdminSite(context)) + { + connectionType = ConnectionType.TenantAdmin; + } + } + + var connection = new PnPConnection(context, connectionType, null, url != null ? url.ToString() : null, tenantAdminUrl, PnPPSVersionTag, InitializationType.FederatedIdentity); + connection.ClientId = appClientId; + connection.Tenant = tenantId; + return connection; + } + } #endregion #region Constructors @@ -705,6 +737,10 @@ private PnPConnection(ClientContext context, { connectionMethod = ConnectionMethod.ManagedIdentity; } + else if (initializationType == InitializationType.FederatedIdentity) + { + connectionMethod = ConnectionMethod.FederatedIdentity; + } if (context != null) { diff --git a/src/Commands/Base/PnPSharePointCmdlet.cs b/src/Commands/Base/PnPSharePointCmdlet.cs index bf29824bd..e56d99490 100644 --- a/src/Commands/Base/PnPSharePointCmdlet.cs +++ b/src/Commands/Base/PnPSharePointCmdlet.cs @@ -49,6 +49,12 @@ protected string AccessToken var defaultResource = $"{resourceUri.Scheme}://{resourceUri.Authority}/.default"; return TokenHandler.GetAzureADWorkloadIdentityTokenAsync(defaultResource).GetAwaiter().GetResult(); } + else if (Connection.ConnectionMethod == ConnectionMethod.FederatedIdentity) + { + var resourceUri = new Uri(Connection.Url); + var defaultResource = $"{resourceUri.Scheme}://{resourceUri.Authority}/.default"; + return TokenHandler.GetFederatedIdentityTokenAsync(Connection.ClientId, Connection.Tenant, defaultResource).GetAwaiter().GetResult(); + } else { if (Connection.Context != null) @@ -81,6 +87,10 @@ public string GraphAccessToken { return TokenHandler.GetAzureADWorkloadIdentityTokenAsync($"https://{Connection.GraphEndPoint}/.default").GetAwaiter().GetResult(); } + else if (Connection?.ConnectionMethod == ConnectionMethod.FederatedIdentity) + { + return TokenHandler.GetFederatedIdentityTokenAsync(Connection.ClientId, Connection.Tenant, $"https://{Connection.GraphEndPoint}/.default").GetAwaiter().GetResult(); + } else { if (Connection?.Context != null) diff --git a/src/Commands/Base/TokenHandler.cs b/src/Commands/Base/TokenHandler.cs index ec4271147..01c15a76b 100644 --- a/src/Commands/Base/TokenHandler.cs +++ b/src/Commands/Base/TokenHandler.cs @@ -1,12 +1,15 @@ using Microsoft.Identity.Client; using Microsoft.SharePoint.Client; using PnP.PowerShell.Commands.Model; +using PnP.PowerShell.Commands.Utilities; using System; +using System.Collections.Generic; using System.Linq; using System.Management.Automation; +using System.Net.Http; using System.Text; +using System.Text.Json; using System.Threading.Tasks; -using PnP.PowerShell.Commands.Utilities; namespace PnP.PowerShell.Commands.Base { @@ -84,12 +87,12 @@ internal static Enums.ResourceTypeName DefineResourceTypeFromAudience(string aud var sanitizedAudience = audience?.TrimEnd('/').ToLowerInvariant(); if (sanitizedAudience.StartsWith("http://")) sanitizedAudience = sanitizedAudience.Substring(7); if (sanitizedAudience.StartsWith("https://")) sanitizedAudience = sanitizedAudience.Substring(8); - + // TODO: Extend with all options Enums.ResourceTypeName resource = sanitizedAudience switch { "graph" or "graph.microsoft.com" or "graph.microsoft.us" or "graph.microsoft.de" or "microsoftgraph.chinacloudapi.cn" or "dod-graph.microsoft.us" or "00000003-0000-0000-c000-000000000000" => Enums.ResourceTypeName.Graph, - "azure" or "management.azure.com" or "management.chinacloudapi.cn" or "management.usgovcloudapi.net" or "management.usgovcloudapi.net" or "management.usgovcloudapi.net" => Enums.ResourceTypeName.AzureManagementApi, + "azure" or "management.azure.com" or "management.chinacloudapi.cn" or "management.usgovcloudapi.net" or "management.usgovcloudapi.net" or "management.usgovcloudapi.net" => Enums.ResourceTypeName.AzureManagementApi, "exchangeonline" or "outlook.office.com" or "outlook.office365.com" => Enums.ResourceTypeName.ExchangeOnline, "flow" or "service.flow.microsoft.com" => Enums.ResourceTypeName.PowerAutomate, "powerapps" or "api.powerapps.com" => Enums.ResourceTypeName.PowerApps, @@ -139,16 +142,16 @@ internal static void EnsureRequiredPermissionsAvailableInAccessTokenAudience(Typ { exceptionTextBuilder.Append($"{string.Join(" and ", permissionEvaluationResponses[i].MissingPermissions.Select(s => s.Scope))}"); - if(i < permissionEvaluationResponses.Length - 1) + if (i < permissionEvaluationResponses.Length - 1) { exceptionTextBuilder.Append(" or "); } } // Log a warning that the permission check failed. Deliberately not throwing an exception here, as the permission attributes might be wrong, thus will try to execute anyway. - PnP.Framework.Diagnostics.Log.Error("TokenHandler",exceptionTextBuilder.ToString().Replace(Environment.NewLine," ")); + PnP.Framework.Diagnostics.Log.Error("TokenHandler", exceptionTextBuilder.ToString().Replace(Environment.NewLine, " ")); //cmdlet.LogWarning(exceptionTextBuilder.ToString()); - } + } /// /// Returns an oAuth JWT access token @@ -165,9 +168,15 @@ internal static string GetAccessToken(string audience, PnPConnection connection) string accessToken = null; if (connection.ConnectionMethod == ConnectionMethod.AzureADWorkloadIdentity) { - PnP.Framework.Diagnostics.Log.Debug("TokenHandler",$"Acquiring token for resource {connection.GraphEndPoint} using Azure AD Workload Identity"); + PnP.Framework.Diagnostics.Log.Debug("TokenHandler", $"Acquiring token for resource {connection.GraphEndPoint} using Azure AD Workload Identity"); //cmdlet.LogDebug("Acquiring token for resource " + connection.GraphEndPoint + " using Azure AD Workload Identity"); - accessToken = GetAzureADWorkloadIdentityTokenAsync($"{audience.TrimEnd('/')}/.default").GetAwaiter().GetResult(); + accessToken = GetAzureADWorkloadIdentityTokenAsync($"{audience.TrimEnd('/')}").GetAwaiter().GetResult(); + } + else if (connection.ConnectionMethod == ConnectionMethod.FederatedIdentity) + { + PnP.Framework.Diagnostics.Log.Debug("TokenHandler", $"Acquiring token for resource {connection.GraphEndPoint} using Federated Identity"); + //cmdlet.LogDebug("Acquiring token for resource " + connection.GraphEndPoint + " using Federated Identity"); + accessToken = GetFederatedIdentityTokenAsync(connection.ClientId, connection.Tenant, $"{audience.TrimEnd('/')}").GetAwaiter().GetResult(); } else { @@ -189,7 +198,7 @@ internal static string GetAccessToken(string audience, PnPConnection connection) } if (string.IsNullOrEmpty(accessToken)) { - PnP.Framework.Diagnostics.Log.Debug("TokenHandler",$"Unable to acquire token for resource {connection.GraphEndPoint}"); + PnP.Framework.Diagnostics.Log.Debug("TokenHandler", $"Unable to acquire token for resource {connection.GraphEndPoint}"); //cmdlet.LogDebug($"Unable to acquire token for resource {connection.GraphEndPoint}"); return null; } @@ -251,5 +260,155 @@ internal static async Task GetAzureADWorkloadIdentityTokenAsync(string r } return result.AccessToken; } + + /// + /// Returns an access token based on a Federated Identity. Only works within Azure components supporting federated identities like GitHub/AzureDevOps. + /// + /// The cmdlet scope in which this code runs. Used to write logging to. + /// The HttpClient that will be reused to fetch the token to avoid port exhaustion + /// The permission scope to be requested, in the format https:///, i.e. https://graph.microsoft.com/Group.Read.All + /// Access token + /// Thrown if unable to retrieve an access token through a managed identity + internal static async Task GetFederatedIdentityTokenAsync(string clientId, string tenant, string requiredScope) + { + var actionsIdTokenRequestUrl = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL"); + var actionsIdTokenRequestToken = Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN"); + var systemOidcRequestUri = Environment.GetEnvironmentVariable("SYSTEM_OIDCREQUESTURI"); + + if (!string.IsNullOrEmpty(actionsIdTokenRequestUrl) && !string.IsNullOrEmpty(actionsIdTokenRequestToken)) + { + Framework.Diagnostics.Log.Debug("TokenHandler", "ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN env variables found. The context is GitHub Actions..."); + + var federationToken = await GetFederationTokenFromGithubAsync(); + return await GetAccessTokenWithFederatedTokenAsync(clientId, tenant, requiredScope, federationToken); + } + else if (!string.IsNullOrEmpty(systemOidcRequestUri)) + { + Framework.Diagnostics.Log.Debug("TokenHandler", "SYSTEM_OIDCREQUESTURI env variable found. The context is Azure DevOps..."); + + var systemAccessToken = Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN"); + if (string.IsNullOrEmpty(systemAccessToken)) + { + throw new PSInvalidOperationException("The SYSTEM_ACCESSTOKEN environment variable is not available. Please check the Azure DevOps pipeline task configuration. It should contain 'SYSTEM_ACCESSTOKEN: $(System.AccessToken)' in the env section."); + } + + var serviceConnectionId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_SERVICE_CONNECTION_ID"); + var serviceConnectionAppId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_CLIENT_ID"); + var serviceConnectionTenantId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_TENANT_ID"); + var useServiceConnection = !string.IsNullOrEmpty(serviceConnectionId) && + !string.IsNullOrEmpty(serviceConnectionAppId) && + !string.IsNullOrEmpty(serviceConnectionTenantId); + + if (!useServiceConnection) + { + throw new PSInvalidOperationException("The Azure DevOps pipeline task is not configured to use a service connection. Please check the pipeline configuration and ensure that the service connection is set up correctly."); + } + + var federationToken = await GetFederationTokenFromAzureDevOpsAsync(serviceConnectionId); + return await GetAccessTokenWithFederatedTokenAsync(clientId, tenant, requiredScope, federationToken); + } + else + { + throw new PSInvalidOperationException("Federated identity is currently only supported in GitHub Actions and Azure DevOps."); + } + } + + private static async Task GetFederationTokenFromGithubAsync() + { + try + { + Framework.Diagnostics.Log.Debug("TokenHandler", "Retrieving GitHub federation token..."); + + var requestUrl = $"{Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL")}&audience={Uri.EscapeDataString("api://AzureADTokenExchange")}"; + + var httpClient = Framework.Http.PnPHttpClient.Instance.GetHttpClient(); + + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, requestUrl); + requestMessage.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_TOKEN")); + requestMessage.Headers.Add("Accept", "application/json"); + requestMessage.Headers.Add("x-anonymous", "true"); + var response = await httpClient.SendAsync(requestMessage); + + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var tokenResponse = JsonSerializer.Deserialize>(content); + + return tokenResponse["value"].ToString(); + } + catch (Exception ex) + { + throw new PSInvalidOperationException($"Failed to retrieve GitHub federation token: {ex.Message}", ex); + } + } + + private static async Task GetFederationTokenFromAzureDevOpsAsync(string serviceConnectionId = null) + { + try + { + Framework.Diagnostics.Log.Debug("TokenHandler", "Retrieving Azure DevOps federation token..."); + + var urlSuffix = !string.IsNullOrEmpty(serviceConnectionId) ? $"&serviceConnectionId={serviceConnectionId}" : ""; + var requestUrl = $"{Environment.GetEnvironmentVariable("SYSTEM_OIDCREQUESTURI")}?api-version=7.1{urlSuffix}"; + + var httpClient = Framework.Http.PnPHttpClient.Instance.GetHttpClient(); + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, requestUrl); + requestMessage.Content = new StringContent("", Encoding.UTF8, "application/json"); + requestMessage.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN")); + requestMessage.Headers.Add("Accept", "application/json"); + requestMessage.Headers.Add("x-anonymous", "true"); + + var response = await httpClient.SendAsync(requestMessage); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var tokenResponse = JsonSerializer.Deserialize>(content); + + return tokenResponse["oidcToken"].ToString(); + } + catch (Exception ex) + { + throw new PSInvalidOperationException($"Failed to retrieve Azure DevOps federation token: {ex.Message}", ex); + } + } + + private static async Task GetAccessTokenWithFederatedTokenAsync(string clientId, string tenant, string resource, string federatedToken) + { + try + { + Framework.Diagnostics.Log.Debug("TokenHandler", "Retrieving Entra ID access Token with federated token..."); + var httpClient = Framework.Http.PnPHttpClient.Instance.GetHttpClient(); + + var queryParams = new List + { + "grant_type=client_credentials", + $"scope={Uri.EscapeDataString($"{resource}/.default")}", + $"client_id={clientId}", + $"client_assertion_type={Uri.EscapeDataString("urn:ietf:params:oauth:client-assertion-type:jwt-bearer")}", + $"client_assertion={federatedToken}" + }; + + var requestData = string.Join("&", queryParams); + var requestUrl = $"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"; + + using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl); + request.Content = new StringContent(requestData, Encoding.UTF8, "application/x-www-form-urlencoded"); + request.Headers.Add("Accept", "application/json"); + request.Headers.Add("x-anonymous", "true"); + + var response = await httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(); + var tokenResponse = JsonSerializer.Deserialize>(responseContent); + + return tokenResponse["access_token"].ToString(); + } + catch (Exception ex) + { + throw new PSInvalidOperationException($"Failed to retrieve access token with federated token: {ex.Message}", ex); + } + } } } \ No newline at end of file diff --git a/src/Commands/Enums/InitializationType.cs b/src/Commands/Enums/InitializationType.cs index b724f212d..39d6253bb 100644 --- a/src/Commands/Enums/InitializationType.cs +++ b/src/Commands/Enums/InitializationType.cs @@ -15,6 +15,7 @@ public enum InitializationType GraphDeviceLogin, ManagedIdentity, EnvironmentVariable, - AzureADWorkloadIdentity + AzureADWorkloadIdentity, + FederatedIdentity } } diff --git a/src/Commands/Model/ConnectionMethod.cs b/src/Commands/Model/ConnectionMethod.cs index b80cfbb28..042b6755e 100644 --- a/src/Commands/Model/ConnectionMethod.cs +++ b/src/Commands/Model/ConnectionMethod.cs @@ -32,7 +32,8 @@ public enum ConnectionMethod /// Using a System Assigned or User Assigned Managed Identity /// ManagedIdentity, - - AzureADWorkloadIdentity + + AzureADWorkloadIdentity, + FederatedIdentity } }