From 83d5c139bdd8c48de043f7db4454e338ee7d497c Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sat, 5 Sep 2026 18:17:02 +0300 Subject: [PATCH 1/6] Improved connect-pnponline cert auth to work with persist login and also added cmdlet to list persist login cmdlets --- CHANGELOG.md | 3 + documentation/Connect-PnPOnline.md | 18 +- documentation/Get-PnPPersistedLogin.md | 39 +++ pages/articles/credentialmanagement.md | 2 +- pages/articles/persistedlogin.md | 24 +- src/Commands/Base/ConnectOnline.cs | 55 +++-- src/Commands/Base/GetPersistedLogin.cs | 17 ++ src/Commands/Base/PnPConnection.cs | 222 ++++++++++++------ src/Commands/Model/TokenCacheConfiguration.cs | 9 + 9 files changed, 292 insertions(+), 97 deletions(-) create mode 100644 documentation/Get-PnPPersistedLogin.md create mode 100644 src/Commands/Base/GetPersistedLogin.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d594a5956c..88685b52bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Current nightly] ### Added +- Added `Get-PnPPersistedLogin` which lists the tenant url, client id and authentication type of every login registered to use the local token cache, so the cache can be inspected without reading it. [#5463](https://github.com/pnp/powershell/pull/5463) +- Added `-PersistLogin` to the certificate based app only parameter sets of `Connect-PnPOnline`, so a connection made with `-CertificatePath`, `-CertificateBase64Encoded`, `-Thumbprint` or the environment variables can reuse its access token from the local cache. The certificate, tenant and client id are still required on each connection, as neither the certificate nor its password is stored. [#5463](https://github.com/pnp/powershell/pull/5463) ### Changed +- Changed `Connect-PnPOnline` to write verbose message on which stored credential it resolved for the url, as it previously picked one up from the credential manager without saying so. [#5463](https://github.com/pnp/powershell/pull/5463) ### Fixed - Using UPNs with an apostrophe in it not working with `Remove-PnPUserProfile`, `Export-PnPUserProfile`, `Export-PnPUserInfo`, and `Remove-PnPUserInfo`. The apostrophe is now escaped in the API request. [#5459](https://github.com/pnp/powershell/pull/5459) diff --git a/documentation/Connect-PnPOnline.md b/documentation/Connect-PnPOnline.md index 97289486ae..a67af1db44 100644 --- a/documentation/Connect-PnPOnline.md +++ b/documentation/Connect-PnPOnline.md @@ -39,7 +39,7 @@ Connect-PnPOnline [-ReturnConnection] [-Url] [-Realm ] -ClientS ### App-Only with Azure Active Directory ```powershell -Connect-PnPOnline [-ReturnConnection] [-Url] [-CreateDrive] [-DriveName ] -ClientId +Connect-PnPOnline [-ReturnConnection] [-Url] [-PersistLogin] [-CreateDrive] [-DriveName ] -ClientId -Tenant [-CertificatePath ] [-CertificateBase64Encoded ] [-CertificatePassword ] [-AzureEnvironment ] [-TenantAdminUrl ] [-ValidateConnection] [-MicrosoftGraphEndPoint ] @@ -48,7 +48,7 @@ Connect-PnPOnline [-ReturnConnection] [-Url] [-CreateDrive] [-DriveName ### App-Only with Azure Active Directory using a certificate from the Windows Certificate Management Store by thumbprint ```powershell -Connect-PnPOnline [-ReturnConnection] [-Url] [-CreateDrive] [-DriveName ] -ClientId +Connect-PnPOnline [-ReturnConnection] [-Url] [-PersistLogin] [-CreateDrive] [-DriveName ] -ClientId -Tenant -Thumbprint [-AzureEnvironment ] [-TenantAdminUrl ] [-ValidateConnection] [-MicrosoftGraphEndPoint ] [-AzureADLoginEndPoint ] [-Connection ] @@ -94,7 +94,7 @@ Connect-PnPOnline [-Url ] -ManagedIdentity -UserAssignedManagedIdentityA ### Environment Variable ```powershell -Connect-PnPOnline [-ReturnConnection] [-Url] [-EnvironmentVariable] [-CurrentCredentials] +Connect-PnPOnline [-ReturnConnection] [-Url] -EnvironmentVariable [-PersistLogin] [-CreateDrive] [-DriveName ] [-RedirectUri ] [-AzureEnvironment ] [-TenantAdminUrl ] [-TransformationOnPrem] [-ValidateConnection] [-MicrosoftGraphEndPoint ] [-AzureADLoginEndPoint ] [-Connection ] @@ -536,13 +536,19 @@ Accept wildcard characters: False ``` ### -PersistLogin -Persist the current access token and related information in a locally stored cache. This cache will be retained between PowerShell sessions and will also be available after a reboot. You only need to provide this switch one time on Connect-PnPOnline cmdlet, it will after that retain the information and reuse it for new connections to the same tenant. Notice that while using a cached token, if you change the permissions of an application registration, the token associated with that registration will not be updated automatically in the cache. You will have to clear the cache entry first and reauthenticate: use `Disconnect-PnPOnline -ClearPersistedLogin` +Persist the current access token and related information in a locally stored cache. This cache will be retained between PowerShell sessions and will also be available after a reboot. You only need to provide this switch one time on Connect-PnPOnline; it will then retain the information and reuse it for new connections to the same tenant and client ID. + +Certificate-based app-only connections can also use this cache. The certificate, tenant and client ID are still required on each connection because the cache does not store the certificate or its password. When a cached app-only access token expires, the supplied certificate is used to acquire a new token. + +Use `Get-PnPPersistedLogin` to enumerate the registered cache entries. Notice that while using a cached token, if you change the permissions of an application registration, the token associated with that registration will not be updated automatically in the cache. You will have to clear the cache entry first and reauthenticate: use `Disconnect-PnPOnline -ClearPersistedLogin`. + +This switch is meant for a workstation you come back to. Do not use it in Azure Automation, Azure Functions, containers or any other environment where the file system does not survive the run or where the work is spread over instances which do not share a user profile: nothing is gained there and each instance authenticates as it normally would. Use a certificate, a managed identity or a workload identity in those environments instead. ```yaml Type: SwitchParameter -Parameter Sets: Credentials, DeviceLogin, Interactive, OSLogin +Parameter Sets: Credentials, Environment Variable, App-Only with Azure Active Directory, App-Only with Azure Active Directory using a certificate from the Windows Certificate Management Store by thumbprint, PnP Management Shell / DeviceLogin, Interactive login for Multi Factor Authentication, OS login -Required: True +Required: False Position: Named Default value: False Accept pipeline input: False diff --git a/documentation/Get-PnPPersistedLogin.md b/documentation/Get-PnPPersistedLogin.md new file mode 100644 index 0000000000..60ca03867c --- /dev/null +++ b/documentation/Get-PnPPersistedLogin.md @@ -0,0 +1,39 @@ +--- +Module Name: PnP.PowerShell +schema: 2.0.0 +applicable: SharePoint Online +online version: https://pnp.github.io/powershell/cmdlets/Get-PnPPersistedLogin.html +external help file: PnP.PowerShell.dll-Help.xml +title: Get-PnPPersistedLogin +--- + +# Get-PnPPersistedLogin + +## SYNOPSIS +Lists persisted login cache registrations + +## SYNTAX + +```powershell +Get-PnPPersistedLogin +``` + +## DESCRIPTION +Returns the SharePoint tenant URL, client ID and authentication type for every login registered to use the local token cache by `Connect-PnPOnline -PersistLogin`. The encrypted tokens themselves are not returned. + +The `AuthenticationType` property identifies delegated and certificate-based app-only logins. The `Enabled` property is `True` for every returned registration; disabled registrations are not returned. An empty result means no persisted logins are registered for the current operating-system user. + +## EXAMPLES + +### EXAMPLE 1 +```powershell +Get-PnPPersistedLogin +``` + +Lists all persisted login cache registrations for the current operating-system user. + +## RELATED LINKS + +[Persisted Login](https://pnp.github.io/powershell/articles/persistedlogin.html) + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/pages/articles/credentialmanagement.md b/pages/articles/credentialmanagement.md index 224b5334e8..df28f7a8f9 100644 --- a/pages/articles/credentialmanagement.md +++ b/pages/articles/credentialmanagement.md @@ -67,7 +67,7 @@ Now you can simply do this: Connect-PnPOnline -Url "https://yourtenant.sharepoint.com" ``` -PnP PowerShell will check the vault if a secret is present with the label matching the URL and it will use those credentials. Notice that a URL like `https://yourtenant.sharepoint.com/sites/demo` will also match the secret. You can create multiple secrets too. PnP PowerShell will then try to match the most complete label first, e.g. a secret ending on /sites/demo1 will proceed the one without that ending. +PnP PowerShell will check the vault if a secret is present with the label matching the URL and it will use those credentials. When this happens, `Connect-PnPOnline` writes a one-line message naming the stored credential it selected. Notice that a URL like `https://yourtenant.sharepoint.com/sites/demo` will also match the secret. You can create multiple secrets too. PnP PowerShell will then try to match the most complete label first, e.g. a secret ending on /sites/demo1 will proceed the one without that ending. ## Removing a secret diff --git a/pages/articles/persistedlogin.md b/pages/articles/persistedlogin.md index 8853c01994..d53aeca402 100644 --- a/pages/articles/persistedlogin.md +++ b/pages/articles/persistedlogin.md @@ -1,31 +1,43 @@ # Persisted Login -Starting with PnP PowerShell 3.0, the `Connect-PnPOnline` cmdlet has been updated to allow `-PersistLogin` to be provided. Documentation for it can be [found here](../cmdlets/Connect-PnPOnline.md#-persistlogin). This parameter allows you to persist the delegated authentication token retrieved through an interactive login in a local file on your machine, which can be used for subsequent connections without requiring re-authentication. +Starting with PnP PowerShell 3.0, the `Connect-PnPOnline` cmdlet allows `-PersistLogin` to be provided. Documentation for it can be [found here](../cmdlets/Connect-PnPOnline.md#-persistlogin). This parameter persists tokens acquired through delegated authentication or certificate-based app-only authentication in a local cache, which can be reused by subsequent connections. This feature is particularly useful for scenarios where you need to run scripts or tasks that require authentication but do not want to enter your credentials every time. The risk obviously will be that anyone with access to your machine can use the token to authenticate against your tenant. ## Where is the token stored -The token is stored in a file in the `%LOCALAPPDATA%\.m365pnppowershell` folder on Windows or `$HOME/.m365pnppowershell` on Linux and MacOS. The file is encrypted using the Data Protection API (DPAPI) on Windows or the Keychain on MacOS and Linux. +The token is stored in a file in the `%LOCALAPPDATA%\.m365pnppowershell` folder on Windows or `$HOME/.m365pnppowershell` on Linux and macOS. The file is encrypted using the Data Protection API (DPAPI) on Windows, Keychain on macOS or Secret Service on Linux. This means that the token is securely stored and cannot be easily accessed by unauthorized users nor can it be copied to another machine as the encryption is tied to the machine on which it has been generated. However, it is important to note that if you share your machine with others, they may be able to access the token if they have access to your user profile. ## How does it work -When you use the `-PersistLogin` parameter with the `Connect-PnPOnline` cmdlet, PnP PowerShell will authenticate you as normal but will also store the refresh token in a local file. The next time you run `Connect-PnPOnline`, PnP PowerShell will check if a valid token already exists in the local file for the tenant or site you are trying to connect to. If a valid token is found, it will be used to authenticate without prompting for credentials. If no valid token is found, PnP PowerShell will prompt for credentials as normal. +When you use the `-PersistLogin` parameter with the `Connect-PnPOnline` cmdlet, PnP PowerShell authenticates as normal and stores the resulting token data in the local cache. The next time you run `Connect-PnPOnline`, PnP PowerShell checks whether a valid token exists for the tenant and client ID. If one is found, it is reused. Delegated authentication can therefore avoid prompting while its refresh token remains valid. + +For certificate-based app-only authentication, the certificate, tenant and client ID must still be supplied on each connection because the cache does not store the certificate or its password. A cached access token is reused while valid; afterwards the supplied certificate is used to acquire a new token. You do not need to specify the `-PersistLogin` parameter again for subsequent connections unless you want to change the behavior. +## Listing persisted logins + +Use `Get-PnPPersistedLogin` to list the tenant URLs, client IDs and authentication types registered to use the persisted token cache: + +```powershell +Get-PnPPersistedLogin +``` + ## Clearing the persisted login If you want to clear the persisted login and remove the stored token, you can connect to the tenant for which you would like to remove the stored token first and then use the `Disconnect-PnPOnline` cmdlet with the `-ClearPersistedLogin` option. Documentation for it can be [found here](../cmdlets/Disconnect-PnPOnline.md#-clearpersistedlogin). This will delete the token from the local file and require you to authenticate again the next time you run `Connect-PnPOnline`. ## FAQ -### Can I use `-PersistLogin` in Azure? +### Can I use `-PersistLogin` in Azure Automation, Azure Functions or a container? + +No. These environments are ephemeral: the file system does not survive the run, and the work may be spread over instances which do not share a user profile, so there is nothing to reuse a token from. The cache is written for a workstation you come back to. -No you cannot, as there are no profiles folders in Azure. +Authenticate as you normally would on each run instead, using a certificate, a managed identity or a workload identity. Leaving `-PersistLogin` off costs nothing there, as the first connection of a run has to authenticate regardless. ### Can I use `-PersistLogin` with an app only context? -No, it is meant to be used for an interactive delegated authentication context only. If you want to use an app only context, you can just use the parameters with the `Connect-PnPOnline` cmdlet that support app only authentication as normal. Documentation for it can be [found here](../cmdlets/Connect-PnPOnline.md#app-only-with-azure-active-directory). +Yes, certificate-based app-only authentication supports `-PersistLogin`. You must continue to provide the certificate, tenant and client ID on each connection because the certificate and its password are not stored. Legacy SharePoint ACS client-secret authentication does not support `-PersistLogin`. ### Do I still need my own application registration in Entra ID when using `-PersistLogin`? diff --git a/src/Commands/Base/ConnectOnline.cs b/src/Commands/Base/ConnectOnline.cs index 5d77fc3631..76aef65a63 100644 --- a/src/Commands/Base/ConnectOnline.cs +++ b/src/Commands/Base/ConnectOnline.cs @@ -283,9 +283,13 @@ public class ConnectOnline : BasePSCmdlet [Parameter(Mandatory = false, ParameterSetName = ParameterSet_DEVICELOGIN)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_OSLOGIN)] [Parameter(Mandatory = false, ParameterSetName = ParameterSet_CREDENTIALS)] + [Parameter(Mandatory = false, ParameterSetName = ParameterSet_ENVIRONMENTVARIABLE)] + [Parameter(Mandatory = false, ParameterSetName = ParameterSet_APPONLYAADCERTIFICATE)] + [Parameter(Mandatory = false, ParameterSetName = ParameterSet_APPONLYAADTHUMBPRINT)] public SwitchParameter PersistLogin; private static readonly string[] sourceArray = ["stop", "ignore", "silentlycontinue"]; + private string storedCredentialName; X509Certificate2 certificate; protected override void ProcessRecord() @@ -623,15 +627,15 @@ private PnPConnection ConnectAppOnlyWithCertificate() } certificate = CertificateHelper.GetCertificateFromPath(this, CertificatePath, CertificatePassword, X509KeyStorageFlags); - if (Connection?.ClientId == ClientId && + if (!PersistLogin && !PnPConnection.CacheEnabled(Url, ClientId, true) && + Connection?.ClientId == ClientId && Connection?.Tenant == Tenant && Connection?.Certificate?.Thumbprint == certificate.Thumbprint) - { ReuseAuthenticationManager(); } - return PnPConnection.CreateWithCert(new Uri(Url), ClientId, Tenant, TenantAdminUrl, AzureEnvironment, certificate, true); + return PnPConnection.CreateWithCert(this, new Uri(Url), ClientId, Tenant, TenantAdminUrl, AzureEnvironment, certificate, PersistLogin, true, ErrorActionSetting); } else if (ParameterSpecified(nameof(CertificateBase64Encoded))) { @@ -644,14 +648,15 @@ private PnPConnection ConnectAppOnlyWithCertificate() } var certificate = new X509Certificate2(certificateBytes, CertificatePassword, X509KeyStorageFlags); - if (Connection?.ClientId == ClientId && + if (!PersistLogin && !PnPConnection.CacheEnabled(Url, ClientId, true) && + Connection?.ClientId == ClientId && Connection?.Tenant == Tenant && Connection?.Certificate?.Thumbprint == certificate.Thumbprint) { ReuseAuthenticationManager(); } // The key container behind this certificate was created by loading the bytes above, so it is ours to remove again on disconnect - return PnPConnection.CreateWithCert(new Uri(Url), ClientId, Tenant, TenantAdminUrl, AzureEnvironment, certificate, true); + return PnPConnection.CreateWithCert(this, new Uri(Url), ClientId, Tenant, TenantAdminUrl, AzureEnvironment, certificate, PersistLogin, true, ErrorActionSetting); } else if (ParameterSpecified(nameof(Thumbprint))) { @@ -667,13 +672,14 @@ private PnPConnection ConnectAppOnlyWithCertificate() { throw new PSArgumentException("The certificate specified does not have a private key.", nameof(Thumbprint)); } - if (Connection?.ClientId == ClientId && - Connection?.Tenant == Tenant && - Connection?.Certificate?.Thumbprint == certificate.Thumbprint) + if (!PersistLogin && !PnPConnection.CacheEnabled(Url, ClientId, true) && + Connection?.ClientId == ClientId && + Connection?.Tenant == Tenant && + Connection?.Certificate?.Thumbprint == certificate.Thumbprint) { ReuseAuthenticationManager(); } - return PnPConnection.CreateWithCert(new Uri(Url), ClientId, Tenant, TenantAdminUrl, AzureEnvironment, certificate); + return PnPConnection.CreateWithCert(this, new Uri(Url), ClientId, Tenant, TenantAdminUrl, AzureEnvironment, certificate, PersistLogin, false, ErrorActionSetting); } else { @@ -703,7 +709,11 @@ private PnPConnection ConnectCredentials(PSCredential credentials, Initializatio if (!CurrentCredentials && credentials == null) { credentials = GetCredentials(); - if (credentials == null) + if (credentials != null) + { + WriteVerbose($"Using stored credential '{storedCredentialName}' for {Url}."); + } + else { credentials = Host.UI.PromptForCredential(Resources.EnterYourCredentials, "", "", ""); @@ -853,7 +863,8 @@ private PnPConnection ConnectEnvironmentVariable(InitializationType initializati } X509Certificate2 certificate = CertificateHelper.GetCertificateFromPath(this, azureCertificatePath, secPassword, X509KeyStorageFlags); - if (Connection?.ClientId == azureClientId && + if (!PersistLogin && !PnPConnection.CacheEnabled(Url, azureClientId, true) && + Connection?.ClientId == azureClientId && Connection?.Tenant == Tenant && Connection?.Certificate?.Thumbprint == certificate.Thumbprint) { @@ -862,7 +873,7 @@ private PnPConnection ConnectEnvironmentVariable(InitializationType initializati LogDebug($"ClientID: {azureClientId}"); - return PnPConnection.CreateWithCert(new Uri(Url), azureClientId, Tenant, TenantAdminUrl, AzureEnvironment, certificate, true); + return PnPConnection.CreateWithCert(this, new Uri(Url), azureClientId, Tenant, TenantAdminUrl, AzureEnvironment, certificate, PersistLogin, true, ErrorActionSetting); } else if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)) @@ -970,7 +981,7 @@ private PSCredential GetCredentials() var connectionUri = new Uri(Url); // Try to get the credentials by full url - PSCredential credentials = Utilities.CredentialManager.GetCredential(Url); + PSCredential credentials = GetStoredCredential(Url); if (credentials == null) { // Try to get the credentials by splitting up the path @@ -982,7 +993,7 @@ private PSCredential GetCredentials() if (!string.IsNullOrEmpty(path)) { var pathUrl = $"{pathString}{path}"; - credentials = Utilities.CredentialManager.GetCredential(pathUrl); + credentials = GetStoredCredential(pathUrl); if (credentials != null) { break; @@ -993,17 +1004,17 @@ private PSCredential GetCredentials() if (credentials == null) { // Try to find the credentials by schema and hostname - credentials = Utilities.CredentialManager.GetCredential(connectionUri.Scheme + "://" + connectionUri.Host); + credentials = GetStoredCredential(connectionUri.Scheme + "://" + connectionUri.Host); if (credentials == null) { // Maybe added with an extra slash? - credentials = Utilities.CredentialManager.GetCredential(connectionUri.Scheme + "://" + connectionUri.Host + "/"); + credentials = GetStoredCredential(connectionUri.Scheme + "://" + connectionUri.Host + "/"); if (credentials == null) { // try to find the credentials by hostname - credentials = Utilities.CredentialManager.GetCredential(connectionUri.Host); + credentials = GetStoredCredential(connectionUri.Host); } } } @@ -1013,6 +1024,16 @@ private PSCredential GetCredentials() return credentials; } + private PSCredential GetStoredCredential(string name) + { + var credentials = Utilities.CredentialManager.GetCredential(name); + if (credentials != null) + { + storedCredentialName = name; + } + return credentials; + } + private string GetAppId() { var connectionUri = new Uri(Url); diff --git a/src/Commands/Base/GetPersistedLogin.cs b/src/Commands/Base/GetPersistedLogin.cs new file mode 100644 index 0000000000..6f24e0761e --- /dev/null +++ b/src/Commands/Base/GetPersistedLogin.cs @@ -0,0 +1,17 @@ +using System.Management.Automation; +using PnP.PowerShell.Commands.Attributes; +using PnP.PowerShell.Commands.Model; + +namespace PnP.PowerShell.Commands.Base +{ + [Cmdlet(VerbsCommon.Get, "PnPPersistedLogin")] + [OutputType(typeof(TokenCacheConfiguration))] + [ApiPermissionsNotRequired(Remarks = "This cmdlet reads the local persisted login configuration and performs no request.")] + public class GetPersistedLogin : BasePSCmdlet + { + protected override void ExecuteCmdlet() + { + WriteObject(PnPConnection.GetPersistedLoginEntries(), true); + } + } +} diff --git a/src/Commands/Base/PnPConnection.cs b/src/Commands/Base/PnPConnection.cs index 7d906efd33..d024e3706d 100644 --- a/src/Commands/Base/PnPConnection.cs +++ b/src/Commands/Base/PnPConnection.cs @@ -158,6 +158,8 @@ internal PnPContext PnPContext internal PnP.Framework.AuthenticationManager AuthenticationManager { get; set; } + internal MsalCacheHelper PersistedLoginCacheHelper { get; set; } + private string _graphEndPoint; /// private static readonly string[] errorActionSourceArray = ["stop", "ignore", "silentlycontinue"]; @@ -357,8 +359,25 @@ internal static PnPConnection CreateWithDeviceLogin(Cmdlet cmdlet, string client } } - internal static PnPConnection CreateWithCert(Uri url, string clientId, string tenant, string tenantAdminUrl, AzureEnvironment azureEnvironment, X509Certificate2 certificate, bool certificateFromFile = false) + internal static PnPConnection CreateWithCert(Cmdlet cmdlet, Uri url, string clientId, string tenant, string tenantAdminUrl, AzureEnvironment azureEnvironment, X509Certificate2 certificate, bool persistLogin, bool certificateFromFile = false, string ErrorActionSetting = null) { + if (persistLogin) + { + EnableCaching(url.ToString(), clientId, true); + } + var cacheEnabled = CacheEnabled(url.ToString(), clientId, true); + if (cacheEnabled && !errorActionSourceArray.Contains(ErrorActionSetting.ToLowerInvariant())) + { + WriteCacheEnabledMessage(cmdlet); + } + + MsalCacheHelper cacheHelper = null; + Action tokenCacheCallback = null; + if (cacheEnabled) + { + tokenCacheCallback = tokenCache => cacheHelper = MSALCacheHelper(tokenCache, url.ToString(), clientId, appOnly: true).GetAwaiter().GetResult(); + } + Framework.AuthenticationManager authManager = null; if (CachedAuthenticationManager != null) { @@ -367,8 +386,9 @@ internal static PnPConnection CreateWithCert(Uri url, string clientId, string te } else { - authManager = Framework.AuthenticationManager.CreateWithCertificate(clientId, certificate, tenant, azureEnvironment: azureEnvironment); + authManager = Framework.AuthenticationManager.CreateWithCertificate(clientId, certificate, tenant, azureEnvironment: azureEnvironment, tokenCacheCallback: tokenCacheCallback); } + using (authManager) { var clientContext = authManager.GetContext(url.ToString()); @@ -391,7 +411,9 @@ internal static PnPConnection CreateWithCert(Uri url, string clientId, string te Certificate = certificate, Tenant = tenant, DeleteCertificateFromCacheOnDisconnect = certificateFromFile, - AzureEnvironment = azureEnvironment + AzureEnvironment = azureEnvironment, + AuthenticationManager = authManager, + PersistedLoginCacheHelper = cacheHelper }; return spoConnection; } @@ -1152,104 +1174,142 @@ internal static void CleanupCryptoMachineKey(X509Certificate2 certificate) } } - private static async Task MSALCacheHelper(ITokenCache tokenCache, string url, string clientid) + private static async Task MSALCacheHelper(ITokenCache tokenCache, string url, string clientid, bool appOnly = false) { const string CacheSchemaName = "pnp.powershell.tokencache"; string cacheDir = Path.Combine(MsalCacheHelper.UserRootDirectory, @".m365pnppowershell"); - if (CacheEnabled(url, clientid)) + if (!CacheEnabled(url, clientid, appOnly)) { - try - { - StorageCreationPropertiesBuilder builder = - new StorageCreationPropertiesBuilder("pnp.msal.cache", cacheDir) - .WithMacKeyChain( - serviceName: $"{CacheSchemaName}.service", - accountName: $"{CacheSchemaName}.account") - .WithLinuxKeyring( - schemaName: CacheSchemaName, - collection: MsalCacheHelper.LinuxKeyRingDefaultCollection, - secretLabel: "MSAL token cache for PnP PowerShell.", - attribute1: new KeyValuePair("Version", "1"), - attribute2: new KeyValuePair("Product", "PnPPowerShell")); - - var storage = builder.Build(); - var cacheHelper = await MsalCacheHelper.CreateAsync(storage).ConfigureAwait(false); - cacheHelper.VerifyPersistence(); - - cacheHelper.RegisterCache(tokenCache); - } - catch (MsalCachePersistenceException) - { - PnP.Framework.Diagnostics.Log.Debug("PnPConnection", "Cache persistence failed. Trying again."); - var storage = - new StorageCreationPropertiesBuilder("pnp.msal.cache", cacheDir) + return null; + } + + var cacheKey = appOnly ? GetAppOnlyCacheKey(url, clientid) : null; + var cacheFileName = appOnly ? $"pnp.msal.app.{cacheKey}.cache" : "pnp.msal.cache"; + var accountName = appOnly ? $"{CacheSchemaName}.app.{cacheKey}" : $"{CacheSchemaName}.account"; + var version = appOnly ? $"app-{cacheKey}" : "1"; + + try + { + StorageCreationPropertiesBuilder builder = + new StorageCreationPropertiesBuilder(cacheFileName, cacheDir) .WithMacKeyChain( serviceName: $"{CacheSchemaName}.service", - accountName: $"{CacheSchemaName}.account") - .WithLinuxUnprotectedFile() - .Build(); - var cacheHelper = await MsalCacheHelper.CreateAsync(storage).ConfigureAwait(false); - - cacheHelper.RegisterCache(tokenCache); + accountName: accountName) + .WithLinuxKeyring( + schemaName: CacheSchemaName, + collection: MsalCacheHelper.LinuxKeyRingDefaultCollection, + secretLabel: "MSAL token cache for PnP PowerShell.", + attribute1: new KeyValuePair("Version", version), + attribute2: new KeyValuePair("Product", "PnPPowerShell")); + + var storage = builder.Build(); + var cacheHelper = await MsalCacheHelper.CreateAsync(storage).ConfigureAwait(false); + cacheHelper.VerifyPersistence(); + + cacheHelper.RegisterCache(tokenCache); + return cacheHelper; + } + catch (MsalCachePersistenceException) + { + if (appOnly) + { + throw new InvalidOperationException("Secure persistence for the app-only token cache is unavailable on this machine. Persisted app-only login was not enabled."); } + + PnP.Framework.Diagnostics.Log.Debug("PnPConnection", "Cache persistence failed. Retrying with an unprotected Linux fallback for delegated logins."); + var storage = + new StorageCreationPropertiesBuilder(cacheFileName, cacheDir) + .WithMacKeyChain( + serviceName: $"{CacheSchemaName}.service", + accountName: accountName) + .WithLinuxUnprotectedFile() + .Build(); + var cacheHelper = await MsalCacheHelper.CreateAsync(storage).ConfigureAwait(false); + + cacheHelper.RegisterCache(tokenCache); + return cacheHelper; } } - internal static bool CacheEnabled(string url, string clientid) + private static string GetAppOnlyCacheKey(string url, string clientid) { - var settings = Settings.Current; + var canonicalUrl = GetCheckUrls(url)[0]; + var value = $"{canonicalUrl}|{clientid}".ToLowerInvariant(); + return Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + } - var cacheEntries = settings.Cache; + internal static bool CacheEnabled(string url, string clientid, bool appOnly = false) + { var urls = GetCheckUrls(url); - var entry = settings.Cache?.FirstOrDefault(c => urls.Contains(c.Url) && c.ClientId == clientid); - if (entry != null && entry.Enabled) - { - return true; - } - return false; + var entry = Settings.Current.Cache?.FirstOrDefault(c => urls.Contains(c.Url, StringComparer.OrdinalIgnoreCase) && string.Equals(c.ClientId, clientid, StringComparison.OrdinalIgnoreCase) && IsAppOnlyCacheEntry(c) == appOnly); + return entry?.Enabled == true; } internal static string GetCacheClientId(string url) { - var settings = Settings.Current; - - var cacheEntries = settings.Cache; var urls = GetCheckUrls(url); - var entry = settings.Cache?.FirstOrDefault(c => urls.Contains(c.Url)); - if (entry != null && entry.Enabled) - { - return entry.ClientId; - } - return null; + var entry = Settings.Current.Cache?.FirstOrDefault(c => urls.Contains(c.Url, StringComparer.OrdinalIgnoreCase) && c.Enabled && !IsAppOnlyCacheEntry(c)); + return entry?.ClientId; + } + + internal static List GetPersistedLoginEntries() + { + return Settings.Current.Cache + .Where(entry => entry.Enabled) + .OrderBy(entry => entry.Url, StringComparer.OrdinalIgnoreCase) + .ThenBy(entry => entry.ClientId, StringComparer.OrdinalIgnoreCase) + .ThenBy(entry => entry.AuthenticationType, StringComparer.OrdinalIgnoreCase) + .Select(entry => new TokenCacheConfiguration + { + Url = entry.Url, + ClientId = entry.ClientId, + AuthenticationType = IsAppOnlyCacheEntry(entry) ? "AppOnly" : "Delegated", + Enabled = entry.Enabled + }) + .ToList(); + } + + private static bool IsAppOnlyCacheEntry(TokenCacheConfiguration entry) + { + return string.Equals(entry.AuthenticationType, "AppOnly", StringComparison.OrdinalIgnoreCase); } private static List GetCheckUrls(string url) { - var urls = new List(); var uri = new Uri(url); - var baseAuthority = uri.Authority; - baseAuthority = baseAuthority.Replace("-admin.sharepoint.com", ".sharepoint.com").Replace("-my.sharepoint.com", ".sharepoint.com"); - var baseUri = new Uri($"https://{baseAuthority}"); - var host = baseUri.Host.Split('.')[0]; - urls = [$"https://{host}.sharepoint.com", $"https://{host}-my.sharepoint.com", $"https://{host}-admin.sharepoint.com"]; + var hostParts = uri.Host.Split('.'); + var tenantName = hostParts[0]; + if (tenantName.EndsWith("-admin", StringComparison.OrdinalIgnoreCase)) + { + tenantName = tenantName[..^6]; + } + else if (tenantName.EndsWith("-my", StringComparison.OrdinalIgnoreCase)) + { + tenantName = tenantName[..^3]; + } - return urls; + var domainSuffix = string.Join('.', hostParts.Skip(1)); + return + [ + $"https://{tenantName}.{domainSuffix}", + $"https://{tenantName}-my.{domainSuffix}", + $"https://{tenantName}-admin.{domainSuffix}" + ]; } - private static void EnableCaching(string url, string clientid) + private static void EnableCaching(string url, string clientid, bool appOnly = false) { var urls = GetCheckUrls(url); - var entry = Settings.Current.Cache?.FirstOrDefault(c => urls.Contains(c.Url) && c.ClientId == clientid); + var authenticationType = appOnly ? "AppOnly" : "Delegated"; + var entry = Settings.Current.Cache?.FirstOrDefault(c => urls.Contains(c.Url, StringComparer.OrdinalIgnoreCase) && string.Equals(c.ClientId, clientid, StringComparison.OrdinalIgnoreCase) && IsAppOnlyCacheEntry(c) == appOnly); if (entry != null) { entry.Enabled = true; } else { - var baseAuthority = new Uri(url).Authority.Replace("-admin.sharepoint.com", ".sharepoint.com").Replace("-my.sharepoint.com", ".sharepoint.com"); - var baseUrl = $"https://{baseAuthority}"; - Settings.Current.Cache.Add(new TokenCacheConfiguration() { ClientId = clientid, Url = baseUrl, Enabled = true }); + Settings.Current.Cache.Add(new TokenCacheConfiguration() { ClientId = clientid, Url = urls[0], AuthenticationType = authenticationType, Enabled = true }); } Settings.Current.Save(); } @@ -1261,13 +1321,41 @@ private static void WriteCacheEnabledMessage(Cmdlet cmdlet) internal static void ClearCache(PnPConnection connection) { + var appOnly = connection.ConnectionMethod == ConnectionMethod.AzureADAppOnly; var urls = GetCheckUrls(connection.Url); - var entry = Settings.Current.Cache?.FirstOrDefault(c => urls.Contains(c.Url) && c.ClientId == connection.ClientId); + var entry = Settings.Current.Cache?.FirstOrDefault(c => urls.Contains(c.Url, StringComparer.OrdinalIgnoreCase) && string.Equals(c.ClientId, connection.ClientId, StringComparison.OrdinalIgnoreCase) && IsAppOnlyCacheEntry(c) == appOnly); + + if (appOnly) + { + var removedPersistedEntry = false; + if (connection.PersistedLoginCacheHelper != null) + { +#pragma warning disable CS0618 // App-only tokens have no accounts to remove individually, and this cache is isolated to one URL/client ID. + connection.PersistedLoginCacheHelper.Clear(); +#pragma warning restore CS0618 + removedPersistedEntry = true; + } + + if (entry != null) + { + Settings.Current.Cache.Remove(entry); + Settings.Current.Save(); + removedPersistedEntry = true; + } + + if (!removedPersistedEntry) + { + PnP.Framework.Diagnostics.Log.Debug("PnPConnection", "No app-only persisted login entry was removed because no cache registration or settings entry was found."); + } + return; + } + if (entry != null) { Settings.Current.Cache.Remove(entry); Settings.Current.Save(); } + if (connection.AuthenticationManager != null) { // Clearing the MSAL token cache can hang when the connection uses the WAM broker: the underlying diff --git a/src/Commands/Model/TokenCacheConfiguration.cs b/src/Commands/Model/TokenCacheConfiguration.cs index 495f4de852..7eb260dd4c 100644 --- a/src/Commands/Model/TokenCacheConfiguration.cs +++ b/src/Commands/Model/TokenCacheConfiguration.cs @@ -1,9 +1,18 @@ namespace PnP.PowerShell.Commands.Model { + /// Describes a persisted login cache registration. public class TokenCacheConfiguration { + /// Gets or sets the SharePoint tenant URL associated with the persisted login. public string Url { get; set; } + + /// Gets or sets the client ID associated with the persisted login. public string ClientId { get; set; } + + /// Gets or sets whether the persisted login uses delegated or app-only authentication. + public string AuthenticationType { get; set; } = "Delegated"; + + /// Gets or sets whether the persisted login is enabled. public bool Enabled { get; set; } } } \ No newline at end of file From c80e1f1404b24a451d553bfb349c30932587e921 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sat, 5 Sep 2026 18:35:44 +0300 Subject: [PATCH 2/6] Enhance persisted login functionality and documentation --- documentation/Connect-PnPOnline.md | 24 +++++++--- documentation/Get-PnPPersistedLogin.md | 18 +++++++- pages/articles/credentialmanagement.md | 2 +- pages/articles/persistedlogin.md | 4 +- src/Commands/Base/ConnectOnline.cs | 2 +- src/Commands/Base/PnPConnection.cs | 64 +++++++++++++++++++++----- 6 files changed, 92 insertions(+), 22 deletions(-) diff --git a/documentation/Connect-PnPOnline.md b/documentation/Connect-PnPOnline.md index a67af1db44..cd89d73d78 100644 --- a/documentation/Connect-PnPOnline.md +++ b/documentation/Connect-PnPOnline.md @@ -17,7 +17,7 @@ Connect to a SharePoint site ### Interactive for Multi Factor Authentication (Default) ```powershell Connect-PnPOnline -Interactive [-ReturnConnection] -Url [-PersistLogin] [-CreateDrive] [-DriveName ] - [-ClientId ] [-AzureEnvironment ] [-TenantAdminUrl ] [-ForceAuthentication] [-ValidateConnection] [-MicrosoftGraphEndPoint ] [-AzureADLoginEndPoint ] [-Connection ] + [-ClientId ] [-Tenant ] [-AzureEnvironment ] [-TenantAdminUrl ] [-ForceAuthentication] [-ValidateConnection] [-MicrosoftGraphEndPoint ] [-AzureADLoginEndPoint ] [-Connection ] ``` ### Credentials @@ -56,7 +56,7 @@ Connect-PnPOnline [-ReturnConnection] [-Url] [-PersistLogin] [-CreateDr ### DeviceLogin ```powershell -Connect-PnPOnline [-ReturnConnection] [-Url] [-PersistLogin] [-CreateDrive] [-DriveName ] -DeviceLogin -Tenant +Connect-PnPOnline [-ReturnConnection] [-Url] [-PersistLogin] [-CreateDrive] [-DriveName ] -DeviceLogin [-Tenant ] [-ClientId ] [-AzureEnvironment ] [-ValidateConnection] [-MicrosoftGraphEndPoint ] [-AzureADLoginEndPoint ] [-Connection ] @@ -95,7 +95,7 @@ Connect-PnPOnline [-Url ] -ManagedIdentity -UserAssignedManagedIdentityA ### Environment Variable ```powershell Connect-PnPOnline [-ReturnConnection] [-Url] -EnvironmentVariable [-PersistLogin] - [-CreateDrive] [-DriveName ] [-RedirectUri ] + [-CreateDrive] [-DriveName ] [-RedirectUri ] [-Tenant ] [-AzureEnvironment ] [-TenantAdminUrl ] [-TransformationOnPrem] [-ValidateConnection] [-MicrosoftGraphEndPoint ] [-AzureADLoginEndPoint ] [-Connection ] ``` @@ -109,7 +109,7 @@ Connect-PnPOnline [-ReturnConnection] [-ValidateConnection] [-Url] ### OS login ```powershell Connect-PnPOnline -OSLogin [-ReturnConnection] [-Url] [-PersistLogin] [-CreateDrive] [-DriveName ] - [-ClientId ] [-AzureEnvironment ] [-TenantAdminUrl ] [-ForceAuthentication] [-ValidateConnection] [-MicrosoftGraphEndPoint ] [-AzureADLoginEndPoint ] [-Connection ] + [-ClientId ] [-Tenant ] [-AzureEnvironment ] [-TenantAdminUrl ] [-ForceAuthentication] [-ValidateConnection] [-MicrosoftGraphEndPoint ] [-AzureADLoginEndPoint ] [-Connection ] ``` ### Federated Identity @@ -606,7 +606,7 @@ The Azure Active Directory tenant name, e.g. mycompany.onmicrosoft.com or mycomp ```yaml Type: String -Parameter Sets: App-Only with Azure Active Directory, App-Only with Azure Active Directory using a certificate from the Windows Certificate Management Store by thumbprint, Environment Variable, Federated Identity +Parameter Sets: App-Only with Azure Active Directory, App-Only with Azure Active Directory using a certificate from the Windows Certificate Management Store by thumbprint Aliases: Required: True @@ -616,6 +616,18 @@ Accept pipeline input: False Accept wildcard characters: False ``` +```yaml +Type: String +Parameter Sets: PnP Management Shell / DeviceLogin, Interactive login for Multi Factor Authentication, Environment Variable, OS login, Federated Identity +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -TenantAdminUrl The url to the Tenant Admin site. If not specified, the cmdlets will assume to connect automatically to https://\[tenantname\]-admin.sharepoint.com where appropriate. @@ -806,7 +818,7 @@ Connects using the necessary environment variables. For more information the req Type: SwitchParameter Parameter Sets: Environment Variable -Required: False +Required: True Position: Named Default value: None Accept pipeline input: False diff --git a/documentation/Get-PnPPersistedLogin.md b/documentation/Get-PnPPersistedLogin.md index 60ca03867c..e0cf5bf6d2 100644 --- a/documentation/Get-PnPPersistedLogin.md +++ b/documentation/Get-PnPPersistedLogin.md @@ -15,7 +15,7 @@ Lists persisted login cache registrations ## SYNTAX ```powershell -Get-PnPPersistedLogin +Get-PnPPersistedLogin [-Verbose] ``` ## DESCRIPTION @@ -32,6 +32,22 @@ Get-PnPPersistedLogin Lists all persisted login cache registrations for the current operating-system user. +## PARAMETERS + +### -Verbose +When provided, additional debug statements will be shown while executing the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ## RELATED LINKS [Persisted Login](https://pnp.github.io/powershell/articles/persistedlogin.html) diff --git a/pages/articles/credentialmanagement.md b/pages/articles/credentialmanagement.md index df28f7a8f9..fca3eb3b98 100644 --- a/pages/articles/credentialmanagement.md +++ b/pages/articles/credentialmanagement.md @@ -67,7 +67,7 @@ Now you can simply do this: Connect-PnPOnline -Url "https://yourtenant.sharepoint.com" ``` -PnP PowerShell will check the vault if a secret is present with the label matching the URL and it will use those credentials. When this happens, `Connect-PnPOnline` writes a one-line message naming the stored credential it selected. Notice that a URL like `https://yourtenant.sharepoint.com/sites/demo` will also match the secret. You can create multiple secrets too. PnP PowerShell will then try to match the most complete label first, e.g. a secret ending on /sites/demo1 will proceed the one without that ending. +PnP PowerShell will check the vault if a secret is present with the label matching the URL and it will use those credentials. When this happens, `Connect-PnPOnline -Verbose` writes a one-line verbose message naming the stored credential it selected. Notice that a URL like `https://yourtenant.sharepoint.com/sites/demo` will also match the secret. You can create multiple secrets too. PnP PowerShell will then try to match the most complete label first, e.g. a secret ending on /sites/demo1 will proceed the one without that ending. ## Removing a secret diff --git a/pages/articles/persistedlogin.md b/pages/articles/persistedlogin.md index d53aeca402..99059abd10 100644 --- a/pages/articles/persistedlogin.md +++ b/pages/articles/persistedlogin.md @@ -7,7 +7,9 @@ This feature is particularly useful for scenarios where you need to run scripts ## Where is the token stored The token is stored in a file in the `%LOCALAPPDATA%\.m365pnppowershell` folder on Windows or `$HOME/.m365pnppowershell` on Linux and macOS. The file is encrypted using the Data Protection API (DPAPI) on Windows, Keychain on macOS or Secret Service on Linux. -This means that the token is securely stored and cannot be easily accessed by unauthorized users nor can it be copied to another machine as the encryption is tied to the machine on which it has been generated. However, it is important to note that if you share your machine with others, they may be able to access the token if they have access to your user profile. +If Secret Service is unavailable on Linux, delegated authentication falls back to an unprotected file. Certificate-based app-only authentication does not use this fallback and instead fails to enable persistence. Ensure access to the cache directory is restricted when the delegated fallback is used. + +Except for that Linux fallback, the token cannot easily be accessed by unauthorized users or copied to another machine because the encryption is tied to the machine on which it was generated. If you share your machine with others, they may still be able to access the token when they have access to your user profile. ## How does it work When you use the `-PersistLogin` parameter with the `Connect-PnPOnline` cmdlet, PnP PowerShell authenticates as normal and stores the resulting token data in the local cache. The next time you run `Connect-PnPOnline`, PnP PowerShell checks whether a valid token exists for the tenant and client ID. If one is found, it is reused. Delegated authentication can therefore avoid prompting while its refresh token remains valid. diff --git a/src/Commands/Base/ConnectOnline.cs b/src/Commands/Base/ConnectOnline.cs index 76aef65a63..2af76cec74 100644 --- a/src/Commands/Base/ConnectOnline.cs +++ b/src/Commands/Base/ConnectOnline.cs @@ -904,7 +904,7 @@ private PnPConnection ConnectEnvironmentVariable(InitializationType initializati PersistLogin, AzureEnvironment, azureClientId, - RedirectUri, TransformationOnPrem, initializationType); + RedirectUri, TransformationOnPrem, initializationType, ErrorActionSetting); } return null; diff --git a/src/Commands/Base/PnPConnection.cs b/src/Commands/Base/PnPConnection.cs index 55cac56849..19f53cce61 100644 --- a/src/Commands/Base/PnPConnection.cs +++ b/src/Commands/Base/PnPConnection.cs @@ -355,11 +355,7 @@ internal static PnPConnection CreateWithDeviceLogin(Cmdlet cmdlet, string client internal static PnPConnection CreateWithCert(Cmdlet cmdlet, Uri url, string clientId, string tenant, string tenantAdminUrl, AzureEnvironment azureEnvironment, X509Certificate2 certificate, bool persistLogin, bool certificateFromFile = false, string ErrorActionSetting = null) { - if (persistLogin) - { - EnableCaching(url.ToString(), clientId, true); - } - var cacheEnabled = CacheEnabled(url.ToString(), clientId, true); + var cacheEnabled = persistLogin || CacheEnabled(url.ToString(), clientId, true); if (cacheEnabled && !errorActionSourceArray.Contains(ErrorActionSetting.ToLowerInvariant())) { WriteCacheEnabledMessage(cmdlet); @@ -369,7 +365,7 @@ internal static PnPConnection CreateWithCert(Cmdlet cmdlet, Uri url, string clie Action tokenCacheCallback = null; if (cacheEnabled) { - tokenCacheCallback = tokenCache => cacheHelper = MSALCacheHelper(tokenCache, url.ToString(), clientId, appOnly: true).GetAwaiter().GetResult(); + tokenCacheCallback = tokenCache => cacheHelper = MSALCacheHelper(tokenCache, url.ToString(), clientId, appOnly: true, cacheRequested: persistLogin).GetAwaiter().GetResult(); } Framework.AuthenticationManager authManager = null; @@ -386,6 +382,27 @@ internal static PnPConnection CreateWithCert(Cmdlet cmdlet, Uri url, string clie using (authManager) { var clientContext = authManager.GetContext(url.ToString()); + if (persistLogin) + { + try + { + EnableCaching(url.ToString(), clientId, true); + } + catch + { + try + { +#pragma warning disable CS0618 // App-only tokens have no accounts to remove individually, and this cache is isolated to one URL/client ID. + cacheHelper?.Clear(); +#pragma warning restore CS0618 + } + catch (Exception ex) + { + Log.Debug("PnPConnection", $"Unable to remove the app-only token after saving its cache registration failed: {ex.Message}"); + } + throw; + } + } var context = PnPClientContext.ConvertFrom(clientContext); context.ExecutingWebRequest += (sender, e) => { @@ -1116,12 +1133,12 @@ internal static void CleanupCryptoMachineKey(X509Certificate2 certificate) } } - private static async Task MSALCacheHelper(ITokenCache tokenCache, string url, string clientid, bool appOnly = false) + private static async Task MSALCacheHelper(ITokenCache tokenCache, string url, string clientid, bool appOnly = false, bool cacheRequested = false) { const string CacheSchemaName = "pnp.powershell.tokencache"; string cacheDir = Path.Combine(MsalCacheHelper.UserRootDirectory, @".m365pnppowershell"); - if (!CacheEnabled(url, clientid, appOnly)) + if (!cacheRequested && !CacheEnabled(url, clientid, appOnly)) { return null; } @@ -1245,15 +1262,38 @@ private static void EnableCaching(string url, string clientid, bool appOnly = fa var urls = GetCheckUrls(url); var authenticationType = appOnly ? "AppOnly" : "Delegated"; var entry = Settings.Current.Cache?.FirstOrDefault(c => urls.Contains(c.Url, StringComparer.OrdinalIgnoreCase) && string.Equals(c.ClientId, clientid, StringComparison.OrdinalIgnoreCase) && IsAppOnlyCacheEntry(c) == appOnly); - if (entry != null) + if (entry?.Enabled == true) { - entry.Enabled = true; + return; + } + + var addedEntry = entry == null; + if (addedEntry) + { + entry = new TokenCacheConfiguration() { ClientId = clientid, Url = urls[0], AuthenticationType = authenticationType, Enabled = true }; + Settings.Current.Cache.Add(entry); } else { - Settings.Current.Cache.Add(new TokenCacheConfiguration() { ClientId = clientid, Url = urls[0], AuthenticationType = authenticationType, Enabled = true }); + entry.Enabled = true; + } + + try + { + Settings.Current.Save(); + } + catch + { + if (addedEntry) + { + Settings.Current.Cache.Remove(entry); + } + else + { + entry.Enabled = false; + } + throw; } - Settings.Current.Save(); } private static void WriteCacheEnabledMessage(Cmdlet cmdlet) From 282a695fb540fd816cc7fc2d72136c0a63492636 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sat, 5 Sep 2026 18:43:59 +0300 Subject: [PATCH 3/6] Fix review comments --- pages/articles/credentialmanagement.md | 2 +- src/Commands/Base/ConnectOnline.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pages/articles/credentialmanagement.md b/pages/articles/credentialmanagement.md index fca3eb3b98..c8bf5d0bcf 100644 --- a/pages/articles/credentialmanagement.md +++ b/pages/articles/credentialmanagement.md @@ -67,7 +67,7 @@ Now you can simply do this: Connect-PnPOnline -Url "https://yourtenant.sharepoint.com" ``` -PnP PowerShell will check the vault if a secret is present with the label matching the URL and it will use those credentials. When this happens, `Connect-PnPOnline -Verbose` writes a one-line verbose message naming the stored credential it selected. Notice that a URL like `https://yourtenant.sharepoint.com/sites/demo` will also match the secret. You can create multiple secrets too. PnP PowerShell will then try to match the most complete label first, e.g. a secret ending on /sites/demo1 will proceed the one without that ending. +PnP PowerShell will check the vault if a secret is present with the label matching the URL and it will use those credentials. When this happens, `Connect-PnPOnline -Verbose` writes a one-line verbose message naming the stored credential it selected. Notice that a URL like `https://yourtenant.sharepoint.com/sites/demo` will also match the secret. You can create multiple secrets too. PnP PowerShell will then try to match the most complete label first, e.g. a secret ending on /sites/demo1 will precede the one without that ending. ## Removing a secret diff --git a/src/Commands/Base/ConnectOnline.cs b/src/Commands/Base/ConnectOnline.cs index 2af76cec74..1a1ec5dc09 100644 --- a/src/Commands/Base/ConnectOnline.cs +++ b/src/Commands/Base/ConnectOnline.cs @@ -289,7 +289,7 @@ public class ConnectOnline : BasePSCmdlet public SwitchParameter PersistLogin; private static readonly string[] sourceArray = ["stop", "ignore", "silentlycontinue"]; - private string storedCredentialName; + private string _storedCredentialName; X509Certificate2 certificate; protected override void ProcessRecord() @@ -711,7 +711,7 @@ private PnPConnection ConnectCredentials(PSCredential credentials, Initializatio credentials = GetCredentials(); if (credentials != null) { - WriteVerbose($"Using stored credential '{storedCredentialName}' for {Url}."); + WriteVerbose($"Using stored credential '{_storedCredentialName}' for {Url}."); } else { @@ -1029,7 +1029,7 @@ private PSCredential GetStoredCredential(string name) var credentials = Utilities.CredentialManager.GetCredential(name); if (credentials != null) { - storedCredentialName = name; + _storedCredentialName = name; } return credentials; } From 4c36bc2bc6e0b7d81ebddd26aae03316fc909964 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sat, 5 Sep 2026 18:51:57 +0300 Subject: [PATCH 4/6] Improve error message for app-only token cache persistence failure --- src/Commands/Base/PnPConnection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Commands/Base/PnPConnection.cs b/src/Commands/Base/PnPConnection.cs index 19f53cce61..95427969ec 100644 --- a/src/Commands/Base/PnPConnection.cs +++ b/src/Commands/Base/PnPConnection.cs @@ -1173,7 +1173,7 @@ private static async Task MSALCacheHelper(ITokenCache tokenCach { if (appOnly) { - throw new InvalidOperationException("Secure persistence for the app-only token cache is unavailable on this machine. Persisted app-only login was not enabled."); + throw new InvalidOperationException("Secure persistence for the app-only token cache is unavailable on this machine, so the persisted app-only login cache cannot be initialized or reused."); } PnP.Framework.Diagnostics.Log.Debug("PnPConnection", "Cache persistence failed. Retrying with an unprotected Linux fallback for delegated logins."); From 1763198b34552013efbf14b146e3c3761ccba2f5 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Sat, 5 Sep 2026 23:04:59 +0300 Subject: [PATCH 5/6] Enhance Disconnect-PnPOnline to warn when no persisted login exists and update documentation for clarity on persisted login behavior --- CHANGELOG.md | 1 + documentation/Disconnect-PnPOnline.md | 13 +++++++++++-- pages/articles/persistedlogin.md | 2 +- src/Commands/Base/DisconnectOnline.cs | 2 +- src/Commands/Base/PnPConnection.cs | 23 ++++++++++++++++++----- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5907e181a0..bca7c73d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ### Changed - Changed `Connect-PnPOnline` to write verbose message on which stored credential it resolved for the url, as it previously picked one up from the credential manager without saying so. [#5463](https://github.com/pnp/powershell/pull/5463) +- Changed `Disconnect-PnPOnline -ClearPersistedLogin` to write a warning when no persisted login exists for the current connection, instead of silently doing nothing. [#5463](https://github.com/pnp/powershell/pull/5463) - Telemetry in PnP PowerShell has been removed due to the costs of collecting the data didn't outweigh the benefits to the PnP PowerShell team to have insights into its usage. The involved cmdlets `Get-PnPPowerShellTelemetryEnabled`, `Enable-PnPPowerShellTelemetry` and `Disable-PnPPowerShellTelemetry` have been marked as deprecated and no longer function, but will stay in v3 for backwards compatibility with existing scripts. These cmdlets will be removed in the next v4 release. All versions of PnP PowerShell will no longer be able to submit telemetry. You might see background requests for this failing. This will not interfear with the normal execution of your PowerShell script. [#5460](https://github.com/pnp/powershell/pull/5460) ### Fixed diff --git a/documentation/Disconnect-PnPOnline.md b/documentation/Disconnect-PnPOnline.md index c237933f9d..278656da02 100644 --- a/documentation/Disconnect-PnPOnline.md +++ b/documentation/Disconnect-PnPOnline.md @@ -33,14 +33,21 @@ Disconnect-PnPOnline This will clear out all active tokens from the current connection +### EXAMPLE 2 +```powershell +Disconnect-PnPOnline -ClearPersistedLogin +``` + +This disconnects and removes the persisted login that was registered with `Connect-PnPOnline -PersistLogin` for the tenant and client ID of the current connection, so the next connection has to authenticate again. + ## PARAMETERS ### -ClearPersistedLogin -Clears the entry in the token cache for this connection. +Removes the persisted login registered with `Connect-PnPOnline -PersistLogin` for the tenant and client ID of the current connection, and deletes the tokens stored for it in the local token cache. For a delegated connection this removes the account from the shared token cache. For a certificate-based app-only connection this deletes the cache file dedicated to that tenant and client ID. A warning is written when no persisted login exists for the current connection; use `Get-PnPPersistedLogin` to see which ones are registered. ```yaml Type: SwitchParameter -Parameter Sets: (All)) +Parameter Sets: (All) Aliases: Required: False @@ -52,4 +59,6 @@ Accept wildcard characters: False ## RELATED LINKS +[Persisted Login](https://pnp.github.io/powershell/articles/persistedlogin.html) + [Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) \ No newline at end of file diff --git a/pages/articles/persistedlogin.md b/pages/articles/persistedlogin.md index 99059abd10..6ba1246d85 100644 --- a/pages/articles/persistedlogin.md +++ b/pages/articles/persistedlogin.md @@ -7,7 +7,7 @@ This feature is particularly useful for scenarios where you need to run scripts ## Where is the token stored The token is stored in a file in the `%LOCALAPPDATA%\.m365pnppowershell` folder on Windows or `$HOME/.m365pnppowershell` on Linux and macOS. The file is encrypted using the Data Protection API (DPAPI) on Windows, Keychain on macOS or Secret Service on Linux. -If Secret Service is unavailable on Linux, delegated authentication falls back to an unprotected file. Certificate-based app-only authentication does not use this fallback and instead fails to enable persistence. Ensure access to the cache directory is restricted when the delegated fallback is used. +If Secret Service is unavailable on Linux, delegated authentication falls back to an unprotected file. Certificate-based app-only authentication does not use this fallback: `Connect-PnPOnline -PersistLogin` fails, and a later connection that reuses an existing registration writes a warning and connects without the cache, so the token is not stored. Ensure access to the cache directory is restricted when the delegated fallback is used. Except for that Linux fallback, the token cannot easily be accessed by unauthorized users or copied to another machine because the encryption is tied to the machine on which it was generated. If you share your machine with others, they may still be able to access the token when they have access to your user profile. diff --git a/src/Commands/Base/DisconnectOnline.cs b/src/Commands/Base/DisconnectOnline.cs index 545c236401..d896f8efc1 100644 --- a/src/Commands/Base/DisconnectOnline.cs +++ b/src/Commands/Base/DisconnectOnline.cs @@ -46,7 +46,7 @@ protected override void ProcessRecord() if (ClearPersistedLogin) { - PnPConnection.ClearCache(PnPConnection.Current); + PnPConnection.ClearCache(PnPConnection.Current, this); } PnPConnection.Current = null; diff --git a/src/Commands/Base/PnPConnection.cs b/src/Commands/Base/PnPConnection.cs index 95427969ec..da81c06f3e 100644 --- a/src/Commands/Base/PnPConnection.cs +++ b/src/Commands/Base/PnPConnection.cs @@ -365,7 +365,20 @@ internal static PnPConnection CreateWithCert(Cmdlet cmdlet, Uri url, string clie Action tokenCacheCallback = null; if (cacheEnabled) { - tokenCacheCallback = tokenCache => cacheHelper = MSALCacheHelper(tokenCache, url.ToString(), clientId, appOnly: true, cacheRequested: persistLogin).GetAwaiter().GetResult(); + tokenCacheCallback = tokenCache => + { + try + { + cacheHelper = MSALCacheHelper(tokenCache, url.ToString(), clientId, appOnly: true, cacheRequested: persistLogin).GetAwaiter().GetResult(); + } + catch (InvalidOperationException ex) when (!persistLogin && ex.InnerException is MsalCachePersistenceException) + { + // The user did not ask for persistence on this call; an earlier -PersistLogin registered it. Failing here would block every + // certificate connection for this tenant and client ID, including the one needed to run Disconnect-PnPOnline -ClearPersistedLogin, + // so connect without the cache and tell the user why. + cmdlet.WriteWarning($"{ex.Message} Connecting without the persisted login cache, so this token will not be reused across sessions. Run 'Disconnect-PnPOnline -ClearPersistedLogin' to remove the persisted login registration for this tenant and client ID, or connect again with -PersistLogin once secure storage is available."); + } + }; } Framework.AuthenticationManager authManager = null; @@ -1169,11 +1182,11 @@ private static async Task MSALCacheHelper(ITokenCache tokenCach cacheHelper.RegisterCache(tokenCache); return cacheHelper; } - catch (MsalCachePersistenceException) + catch (MsalCachePersistenceException ex) { if (appOnly) { - throw new InvalidOperationException("Secure persistence for the app-only token cache is unavailable on this machine, so the persisted app-only login cache cannot be initialized or reused."); + throw new InvalidOperationException("Secure persistence for the app-only token cache is unavailable on this machine, so the persisted app-only login cache cannot be initialized or reused.", ex); } PnP.Framework.Diagnostics.Log.Debug("PnPConnection", "Cache persistence failed. Retrying with an unprotected Linux fallback for delegated logins."); @@ -1301,7 +1314,7 @@ private static void WriteCacheEnabledMessage(Cmdlet cmdlet) cmdlet.WriteVerbose("Connecting using token cache. See https://pnp.github.io/powershell/articles/persistedlogin.html for more information."); } - internal static void ClearCache(PnPConnection connection) + internal static void ClearCache(PnPConnection connection, Cmdlet cmdlet = null) { var appOnly = connection.ConnectionMethod == ConnectionMethod.AzureADAppOnly; var urls = GetCheckUrls(connection.Url); @@ -1327,7 +1340,7 @@ internal static void ClearCache(PnPConnection connection) if (!removedPersistedEntry) { - PnP.Framework.Diagnostics.Log.Debug("PnPConnection", "No app-only persisted login entry was removed because no cache registration or settings entry was found."); + cmdlet?.WriteWarning($"No persisted login was found for {connection.Url} with client ID {connection.ClientId}, so nothing was cleared. Use Get-PnPPersistedLogin to list the registered persisted logins."); } return; } From c06f290188bb982915ad853d8927aa92e77176e6 Mon Sep 17 00:00:00 2001 From: Gautam Sheth Date: Wed, 23 Sep 2026 22:10:08 +0300 Subject: [PATCH 6/6] Enhance app-only token persistence and error handling --- documentation/Connect-PnPOnline.md | 6 + documentation/Disconnect-PnPOnline.md | 4 +- pages/articles/persistedlogin.md | 8 +- src/Commands/Base/ConnectOnline.cs | 16 ++- src/Commands/Base/DisconnectOnline.cs | 17 ++- src/Commands/Base/PnPConnection.cs | 134 +++++++++++------- src/Commands/Model/Settings.cs | 36 +++-- src/Commands/Properties/Resources.Designer.cs | 81 +++++++++++ src/Commands/Properties/Resources.resx | 27 ++++ src/Commands/Utilities/AppOnlyTokenCache.cs | 129 +++++++++++++++++ 10 files changed, 384 insertions(+), 74 deletions(-) create mode 100644 src/Commands/Utilities/AppOnlyTokenCache.cs diff --git a/documentation/Connect-PnPOnline.md b/documentation/Connect-PnPOnline.md index cd89d73d78..fa4c7806b3 100644 --- a/documentation/Connect-PnPOnline.md +++ b/documentation/Connect-PnPOnline.md @@ -540,6 +540,12 @@ Persist the current access token and related information in a locally stored cac Certificate-based app-only connections can also use this cache. The certificate, tenant and client ID are still required on each connection because the cache does not store the certificate or its password. When a cached app-only access token expires, the supplied certificate is used to acquire a new token. +App-only cache read and write failures are reported as errors, including failures during later token acquisition. An explicit `-PersistLogin` also fails if secure storage is unavailable. When reusing an existing registration without this switch, unavailable secure storage causes a warning and a connection without persistence; previously stored tokens and the registration are left intact. + +If the persisted-login settings file is unreadable or invalid, a certificate connection without `-PersistLogin` writes a warning and proceeds without persistence. An explicit `-PersistLogin` fails before acquiring or storing a token, so invalid settings are not replaced with an empty configuration. Repair the settings file or restore read access before retrying persistence. + +You can enable persistence on an existing environment-variable credentials connection by connecting again with `-EnvironmentVariable -PersistLogin`. A new authentication manager is created so the cache is attached before authentication. + Use `Get-PnPPersistedLogin` to enumerate the registered cache entries. Notice that while using a cached token, if you change the permissions of an application registration, the token associated with that registration will not be updated automatically in the cache. You will have to clear the cache entry first and reauthenticate: use `Disconnect-PnPOnline -ClearPersistedLogin`. This switch is meant for a workstation you come back to. Do not use it in Azure Automation, Azure Functions, containers or any other environment where the file system does not survive the run or where the work is spread over instances which do not share a user profile: nothing is gained there and each instance authenticates as it normally would. Use a certificate, a managed identity or a workload identity in those environments instead. diff --git a/documentation/Disconnect-PnPOnline.md b/documentation/Disconnect-PnPOnline.md index 278656da02..923b7e6486 100644 --- a/documentation/Disconnect-PnPOnline.md +++ b/documentation/Disconnect-PnPOnline.md @@ -43,7 +43,9 @@ This disconnects and removes the persisted login that was registered with `Conne ## PARAMETERS ### -ClearPersistedLogin -Removes the persisted login registered with `Connect-PnPOnline -PersistLogin` for the tenant and client ID of the current connection, and deletes the tokens stored for it in the local token cache. For a delegated connection this removes the account from the shared token cache. For a certificate-based app-only connection this deletes the cache file dedicated to that tenant and client ID. A warning is written when no persisted login exists for the current connection; use `Get-PnPPersistedLogin` to see which ones are registered. +Removes the persisted login registered with `Connect-PnPOnline -PersistLogin` for the tenant and client ID of the current connection, and clears its local token cache. For a delegated connection this attempts to remove the account from the shared token cache. For a certificate-based app-only connection this deletes the cache dedicated to that tenant and client ID from secure storage. A warning is written when no persisted login is registered for the current connection, for both delegated and app-only authentication; use `Get-PnPPersistedLogin` to see which ones are registered. + +If app-only token deletion fails, for example because the cache file is locked or secure storage is unavailable, the cmdlet reports an error and retains the connection and registration so cleanup can be retried. This also applies when the connection was established without persistence after a secure-storage warning. Restore access to secure storage and run `Disconnect-PnPOnline -ClearPersistedLogin` again. ```yaml Type: SwitchParameter diff --git a/pages/articles/persistedlogin.md b/pages/articles/persistedlogin.md index 6ba1246d85..a63e6a3c68 100644 --- a/pages/articles/persistedlogin.md +++ b/pages/articles/persistedlogin.md @@ -7,7 +7,7 @@ This feature is particularly useful for scenarios where you need to run scripts ## Where is the token stored The token is stored in a file in the `%LOCALAPPDATA%\.m365pnppowershell` folder on Windows or `$HOME/.m365pnppowershell` on Linux and macOS. The file is encrypted using the Data Protection API (DPAPI) on Windows, Keychain on macOS or Secret Service on Linux. -If Secret Service is unavailable on Linux, delegated authentication falls back to an unprotected file. Certificate-based app-only authentication does not use this fallback: `Connect-PnPOnline -PersistLogin` fails, and a later connection that reuses an existing registration writes a warning and connects without the cache, so the token is not stored. Ensure access to the cache directory is restricted when the delegated fallback is used. +If Secret Service is unavailable on Linux, delegated authentication falls back to an unprotected file. Certificate-based app-only authentication does not use this fallback: `Connect-PnPOnline -PersistLogin` fails, and a later connection that reuses an existing registration writes a warning and connects without the cache, so the new token is not stored. Existing registrations and previously stored tokens are left intact. Ensure access to the cache directory is restricted when the delegated fallback is used. Except for that Linux fallback, the token cannot easily be accessed by unauthorized users or copied to another machine because the encryption is tied to the machine on which it was generated. If you share your machine with others, they may still be able to access the token when they have access to your user profile. @@ -16,6 +16,10 @@ When you use the `-PersistLogin` parameter with the `Connect-PnPOnline` cmdlet, For certificate-based app-only authentication, the certificate, tenant and client ID must still be supplied on each connection because the cache does not store the certificate or its password. A cached access token is reused while valid; afterwards the supplied certificate is used to acquire a new token. +App-only cache reads and writes report storage failures rather than silently continuing. This applies both when connecting and when acquiring tokens later. Each app-only cache is isolated by SharePoint tenant hostname and client ID, with the corresponding root, admin and OneDrive hostnames sharing a cache. Connecting to an unregistered hostname, such as another multi-geo location, does not inherit persistence from the previous connection. + +An unreadable or invalid `settings.json` does not prevent certificate authentication when `-PersistLogin` is omitted: the connection writes a warning and proceeds without persistence. Explicit `-PersistLogin` instead fails before authentication. Neither path overwrites the invalid settings or deletes previously cached tokens; repair the file or restore its read permissions before retrying persistence. + You do not need to specify the `-PersistLogin` parameter again for subsequent connections unless you want to change the behavior. ## Listing persisted logins @@ -29,6 +33,8 @@ Get-PnPPersistedLogin ## Clearing the persisted login If you want to clear the persisted login and remove the stored token, you can connect to the tenant for which you would like to remove the stored token first and then use the `Disconnect-PnPOnline` cmdlet with the `-ClearPersistedLogin` option. Documentation for it can be [found here](../cmdlets/Disconnect-PnPOnline.md#-clearpersistedlogin). This will delete the token from the local file and require you to authenticate again the next time you run `Connect-PnPOnline`. +For an app-only connection, the registration is removed only after secure-storage deletion succeeds. If deletion fails, the command reports an error and retains the connection and registration. Restore access to secure storage, then retry `Disconnect-PnPOnline -ClearPersistedLogin`. A previous warning that the connection is running without persistence does not mean that old tokens were deleted. + ## FAQ ### Can I use `-PersistLogin` in Azure Automation, Azure Functions or a container? diff --git a/src/Commands/Base/ConnectOnline.cs b/src/Commands/Base/ConnectOnline.cs index 1a1ec5dc09..0f16666c03 100644 --- a/src/Commands/Base/ConnectOnline.cs +++ b/src/Commands/Base/ConnectOnline.cs @@ -627,7 +627,7 @@ private PnPConnection ConnectAppOnlyWithCertificate() } certificate = CertificateHelper.GetCertificateFromPath(this, CertificatePath, CertificatePassword, X509KeyStorageFlags); - if (!PersistLogin && !PnPConnection.CacheEnabled(Url, ClientId, true) && + if (!PersistLogin && Connection?.ClientId == ClientId && Connection?.Tenant == Tenant && Connection?.Certificate?.Thumbprint == certificate.Thumbprint) @@ -648,7 +648,7 @@ private PnPConnection ConnectAppOnlyWithCertificate() } var certificate = new X509Certificate2(certificateBytes, CertificatePassword, X509KeyStorageFlags); - if (!PersistLogin && !PnPConnection.CacheEnabled(Url, ClientId, true) && + if (!PersistLogin && Connection?.ClientId == ClientId && Connection?.Tenant == Tenant && Connection?.Certificate?.Thumbprint == certificate.Thumbprint) @@ -672,7 +672,7 @@ private PnPConnection ConnectAppOnlyWithCertificate() { throw new PSArgumentException("The certificate specified does not have a private key.", nameof(Thumbprint)); } - if (!PersistLogin && !PnPConnection.CacheEnabled(Url, ClientId, true) && + if (!PersistLogin && Connection?.ClientId == ClientId && Connection?.Tenant == Tenant && Connection?.Certificate?.Thumbprint == certificate.Thumbprint) @@ -763,7 +763,7 @@ private PnPConnection ConnectCredentials(PSCredential credentials, Initializatio PersistLogin, AzureEnvironment, ClientId, - RedirectUri, TransformationOnPrem, initializationType); + RedirectUri, TransformationOnPrem, initializationType, ErrorActionSetting); } @@ -863,7 +863,7 @@ private PnPConnection ConnectEnvironmentVariable(InitializationType initializati } X509Certificate2 certificate = CertificateHelper.GetCertificateFromPath(this, azureCertificatePath, secPassword, X509KeyStorageFlags); - if (!PersistLogin && !PnPConnection.CacheEnabled(Url, azureClientId, true) && + if (!PersistLogin && Connection?.ClientId == azureClientId && Connection?.Tenant == Tenant && Connection?.Certificate?.Thumbprint == certificate.Thumbprint) @@ -1111,6 +1111,12 @@ protected override void StopProcessing() private void ReuseAuthenticationManager() { + if (PersistLogin || Connection.PersistedAppOnlyTokenCache != null) + { + PnPConnection.CachedAuthenticationManager = null; + return; + } + var contextSettings = Connection.Context?.GetContextSettings(); PnPConnection.CachedAuthenticationManager = contextSettings?.AuthenticationManager; } diff --git a/src/Commands/Base/DisconnectOnline.cs b/src/Commands/Base/DisconnectOnline.cs index d896f8efc1..3e873dda2c 100644 --- a/src/Commands/Base/DisconnectOnline.cs +++ b/src/Commands/Base/DisconnectOnline.cs @@ -32,6 +32,18 @@ protected override void ProcessRecord() } } + if (ClearPersistedLogin) + { + try + { + PnPConnection.ClearCache(PnPConnection.Current, this); + } + catch (Exception ex) when (ex is not PipelineStoppedException && ex is not ActionPreferenceStopException) + { + ThrowTerminatingError(new ErrorRecord(new InvalidOperationException(Properties.Resources.PersistedLoginClearFailed, ex), "PersistedLoginClearFailed", ErrorCategory.WriteError, PnPConnection.Current)); + } + } + Environment.SetEnvironmentVariable("PNPPSHOST", string.Empty); Environment.SetEnvironmentVariable("PNPPSSITE", string.Empty); @@ -44,11 +56,6 @@ protected override void ProcessRecord() PnPConnection.Current.Certificate = null; } - if (ClearPersistedLogin) - { - PnPConnection.ClearCache(PnPConnection.Current, this); - } - PnPConnection.Current = null; var provider = SessionState.Provider.GetAll().FirstOrDefault(p => p.Name.Equals(SPOProvider.PSProviderName, StringComparison.InvariantCultureIgnoreCase)); diff --git a/src/Commands/Base/PnPConnection.cs b/src/Commands/Base/PnPConnection.cs index da81c06f3e..375a6c1309 100644 --- a/src/Commands/Base/PnPConnection.cs +++ b/src/Commands/Base/PnPConnection.cs @@ -19,6 +19,7 @@ using System.Reflection; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using TextCopy; @@ -152,7 +153,7 @@ internal PnPContext PnPContext internal PnP.Framework.AuthenticationManager AuthenticationManager { get; set; } - internal MsalCacheHelper PersistedLoginCacheHelper { get; set; } + internal AppOnlyTokenCache PersistedAppOnlyTokenCache { get; set; } private string _graphEndPoint; /// @@ -355,13 +356,13 @@ internal static PnPConnection CreateWithDeviceLogin(Cmdlet cmdlet, string client internal static PnPConnection CreateWithCert(Cmdlet cmdlet, Uri url, string clientId, string tenant, string tenantAdminUrl, AzureEnvironment azureEnvironment, X509Certificate2 certificate, bool persistLogin, bool certificateFromFile = false, string ErrorActionSetting = null) { - var cacheEnabled = persistLogin || CacheEnabled(url.ToString(), clientId, true); + var cacheEnabled = ResolveAppOnlyCacheUsage(cmdlet, url, persistLogin, () => CacheEnabled(url.ToString(), clientId, true)); if (cacheEnabled && !errorActionSourceArray.Contains(ErrorActionSetting.ToLowerInvariant())) { WriteCacheEnabledMessage(cmdlet); } - MsalCacheHelper cacheHelper = null; + AppOnlyTokenCache cacheHelper = null; Action tokenCacheCallback = null; if (cacheEnabled) { @@ -369,26 +370,31 @@ internal static PnPConnection CreateWithCert(Cmdlet cmdlet, Uri url, string clie { try { - cacheHelper = MSALCacheHelper(tokenCache, url.ToString(), clientId, appOnly: true, cacheRequested: persistLogin).GetAwaiter().GetResult(); + var appOnlyCache = new AppOnlyTokenCache(GetTokenCacheStorageProperties(url.ToString(), clientId, appOnly: true)); + appOnlyCache.VerifyPersistence(); + appOnlyCache.RegisterCache(tokenCache); + cacheHelper = appOnlyCache; } - catch (InvalidOperationException ex) when (!persistLogin && ex.InnerException is MsalCachePersistenceException) + catch (MsalCachePersistenceException ex) { - // The user did not ask for persistence on this call; an earlier -PersistLogin registered it. Failing here would block every - // certificate connection for this tenant and client ID, including the one needed to run Disconnect-PnPOnline -ClearPersistedLogin, - // so connect without the cache and tell the user why. - cmdlet.WriteWarning($"{ex.Message} Connecting without the persisted login cache, so this token will not be reused across sessions. Run 'Disconnect-PnPOnline -ClearPersistedLogin' to remove the persisted login registration for this tenant and client ID, or connect again with -PersistLogin once secure storage is available."); + if (persistLogin) + { + cmdlet.ThrowTerminatingError(new ErrorRecord(new InvalidOperationException(Resources.PersistedLoginSecureStorageUnavailable, ex), "PersistedLoginSecureStorageUnavailable", ErrorCategory.ResourceUnavailable, url)); + } + cmdlet.WriteWarning(Resources.PersistedLoginSecureStorageWarning); } }; } Framework.AuthenticationManager authManager = null; - if (CachedAuthenticationManager != null) + if (CachedAuthenticationManager != null && !cacheEnabled) { authManager = CachedAuthenticationManager; CachedAuthenticationManager = null; } else { + CachedAuthenticationManager = null; authManager = Framework.AuthenticationManager.CreateWithCertificate(clientId, certificate, tenant, azureEnvironment: azureEnvironment, tokenCacheCallback: tokenCacheCallback); } @@ -405,13 +411,11 @@ internal static PnPConnection CreateWithCert(Cmdlet cmdlet, Uri url, string clie { try { -#pragma warning disable CS0618 // App-only tokens have no accounts to remove individually, and this cache is isolated to one URL/client ID. cacheHelper?.Clear(); -#pragma warning restore CS0618 } catch (Exception ex) { - Log.Debug("PnPConnection", $"Unable to remove the app-only token after saving its cache registration failed: {ex.Message}"); + cmdlet.WriteWarning(string.Format(System.Globalization.CultureInfo.CurrentCulture, Resources.PersistedLoginRollbackFailed, ex.Message)); } throw; } @@ -437,7 +441,7 @@ internal static PnPConnection CreateWithCert(Cmdlet cmdlet, Uri url, string clie DeleteCertificateFromCacheOnDisconnect = certificateFromFile, AzureEnvironment = azureEnvironment, AuthenticationManager = authManager, - PersistedLoginCacheHelper = cacheHelper + PersistedAppOnlyTokenCache = cacheHelper }; return spoConnection; } @@ -546,9 +550,9 @@ internal static PnPConnection CreateWithCredentials(Cmdlet cmdlet, Uri url, PSCr } else { - authManager = PnP.Framework.AuthenticationManager.CreateWithCredentials(clientId, credentials.UserName, credentials.Password, redirectUrl, azureEnvironment, tokenCacheCallback: async (tokenCache) => + authManager = PnP.Framework.AuthenticationManager.CreateWithCredentials(clientId, credentials.UserName, credentials.Password, redirectUrl, azureEnvironment, tokenCacheCallback: (tokenCache) => { - await MSALCacheHelper(tokenCache, url.ToString(), clientId); + MSALCacheHelper(tokenCache, url.ToString(), clientId).GetAwaiter().GetResult(); }); } using (authManager) @@ -1146,25 +1150,35 @@ internal static void CleanupCryptoMachineKey(X509Certificate2 certificate) } } - private static async Task MSALCacheHelper(ITokenCache tokenCache, string url, string clientid, bool appOnly = false, bool cacheRequested = false) + private static bool ResolveAppOnlyCacheUsage(Cmdlet cmdlet, Uri url, bool persistLogin, Func readRegistration) { - const string CacheSchemaName = "pnp.powershell.tokencache"; - string cacheDir = Path.Combine(MsalCacheHelper.UserRootDirectory, @".m365pnppowershell"); - - if (!cacheRequested && !CacheEnabled(url, clientid, appOnly)) + try { - return null; + // Validate settings even for an explicit request, before any token is acquired or written. + return readRegistration() || persistLogin; } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is JsonException) + { + if (persistLogin) + { + cmdlet.ThrowTerminatingError(new ErrorRecord(new InvalidOperationException(Resources.PersistedLoginSettingsUnavailable, ex), "PersistedLoginSettingsUnavailable", ErrorCategory.ReadError, url)); + } + cmdlet.WriteWarning(string.Format(System.Globalization.CultureInfo.CurrentCulture, Resources.PersistedLoginSettingsWarning, ex.Message)); + return false; + } + } + + private static StorageCreationProperties GetTokenCacheStorageProperties(string url, string clientid, bool appOnly) + { + const string CacheSchemaName = "pnp.powershell.tokencache"; + string cacheDir = Path.Combine(MsalCacheHelper.UserRootDirectory, @".m365pnppowershell"); var cacheKey = appOnly ? GetAppOnlyCacheKey(url, clientid) : null; var cacheFileName = appOnly ? $"pnp.msal.app.{cacheKey}.cache" : "pnp.msal.cache"; var accountName = appOnly ? $"{CacheSchemaName}.app.{cacheKey}" : $"{CacheSchemaName}.account"; var version = appOnly ? $"app-{cacheKey}" : "1"; - try - { - StorageCreationPropertiesBuilder builder = - new StorageCreationPropertiesBuilder(cacheFileName, cacheDir) + return new StorageCreationPropertiesBuilder(cacheFileName, cacheDir) .WithMacKeyChain( serviceName: $"{CacheSchemaName}.service", accountName: accountName) @@ -1173,31 +1187,37 @@ private static async Task MSALCacheHelper(ITokenCache tokenCach collection: MsalCacheHelper.LinuxKeyRingDefaultCollection, secretLabel: "MSAL token cache for PnP PowerShell.", attribute1: new KeyValuePair("Version", version), - attribute2: new KeyValuePair("Product", "PnPPowerShell")); + attribute2: new KeyValuePair("Product", "PnPPowerShell")) + .Build(); + } - var storage = builder.Build(); + private static async Task MSALCacheHelper(ITokenCache tokenCache, string url, string clientid) + { + if (!CacheEnabled(url, clientid)) + { + return null; + } + + var storage = GetTokenCacheStorageProperties(url, clientid, appOnly: false); + try + { var cacheHelper = await MsalCacheHelper.CreateAsync(storage).ConfigureAwait(false); cacheHelper.VerifyPersistence(); cacheHelper.RegisterCache(tokenCache); return cacheHelper; } - catch (MsalCachePersistenceException ex) + catch (MsalCachePersistenceException) { - if (appOnly) - { - throw new InvalidOperationException("Secure persistence for the app-only token cache is unavailable on this machine, so the persisted app-only login cache cannot be initialized or reused.", ex); - } - PnP.Framework.Diagnostics.Log.Debug("PnPConnection", "Cache persistence failed. Retrying with an unprotected Linux fallback for delegated logins."); - var storage = - new StorageCreationPropertiesBuilder(cacheFileName, cacheDir) + var fallbackStorage = + new StorageCreationPropertiesBuilder(storage.CacheFileName, storage.CacheDirectory) .WithMacKeyChain( - serviceName: $"{CacheSchemaName}.service", - accountName: accountName) + serviceName: storage.MacKeyChainServiceName, + accountName: storage.MacKeyChainAccountName) .WithLinuxUnprotectedFile() .Build(); - var cacheHelper = await MsalCacheHelper.CreateAsync(storage).ConfigureAwait(false); + var cacheHelper = await MsalCacheHelper.CreateAsync(fallbackStorage).ConfigureAwait(false); cacheHelper.RegisterCache(tokenCache); return cacheHelper; @@ -1317,30 +1337,38 @@ private static void WriteCacheEnabledMessage(Cmdlet cmdlet) internal static void ClearCache(PnPConnection connection, Cmdlet cmdlet = null) { var appOnly = connection.ConnectionMethod == ConnectionMethod.AzureADAppOnly; - var urls = GetCheckUrls(connection.Url); + var urls = string.IsNullOrEmpty(connection.Url) ? [] : GetCheckUrls(connection.Url); var entry = Settings.Current.Cache?.FirstOrDefault(c => urls.Contains(c.Url, StringComparer.OrdinalIgnoreCase) && string.Equals(c.ClientId, connection.ClientId, StringComparison.OrdinalIgnoreCase) && IsAppOnlyCacheEntry(c) == appOnly); + if (entry == null && connection.PersistedAppOnlyTokenCache == null) + { + cmdlet?.WriteWarning(string.Format(System.Globalization.CultureInfo.CurrentCulture, Resources.PersistedLoginNotFound, connection.Url, connection.ClientId)); + } + if (appOnly) { - var removedPersistedEntry = false; - if (connection.PersistedLoginCacheHelper != null) + if (entry == null && connection.PersistedAppOnlyTokenCache == null) { -#pragma warning disable CS0618 // App-only tokens have no accounts to remove individually, and this cache is isolated to one URL/client ID. - connection.PersistedLoginCacheHelper.Clear(); -#pragma warning restore CS0618 - removedPersistedEntry = true; + return; } + var cacheHelper = connection.PersistedAppOnlyTokenCache + ?? new AppOnlyTokenCache(GetTokenCacheStorageProperties(connection.Url, connection.ClientId, appOnly: true)); + cacheHelper.Clear(); + if (entry != null) { + var index = Settings.Current.Cache.IndexOf(entry); Settings.Current.Cache.Remove(entry); - Settings.Current.Save(); - removedPersistedEntry = true; - } - - if (!removedPersistedEntry) - { - cmdlet?.WriteWarning($"No persisted login was found for {connection.Url} with client ID {connection.ClientId}, so nothing was cleared. Use Get-PnPPersistedLogin to list the registered persisted logins."); + try + { + Settings.Current.Save(); + } + catch + { + Settings.Current.Cache.Insert(index, entry); + throw; + } } return; } diff --git a/src/Commands/Model/Settings.cs b/src/Commands/Model/Settings.cs index d2da7eaff7..59daecdc50 100644 --- a/src/Commands/Model/Settings.cs +++ b/src/Commands/Model/Settings.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Identity.Client.Extensions.Msal; +using PnP.PowerShell.Commands.Properties; namespace PnP.PowerShell.Commands.Model { @@ -36,21 +37,38 @@ public static Settings Current { if (_settings == null) { - // try to load settings var settingsFile = Path.Combine(MsalCacheHelper.UserRootDirectory, ".m365pnppowershell", "settings.json"); - if (System.IO.File.Exists(settingsFile)) - { - _settings = JsonSerializer.Deserialize(System.IO.File.ReadAllText(settingsFile)); ; - } - else - { - _settings = new Settings(); - } + _settings = Load(settingsFile); } return _settings; } } + /// Loads settings without treating unreadable or invalid files as empty configuration. + internal static Settings Load(string settingsFile) + { + string json; + try + { + json = File.ReadAllText(settingsFile); + } + catch (FileNotFoundException) + { + return new Settings(); + } + catch (DirectoryNotFoundException) + { + return new Settings(); + } + + var settings = JsonSerializer.Deserialize(json); + if (settings == null || settings.Cache.Contains(null)) + { + throw new JsonException(Resources.PersistedLoginSettingsInvalid); + } + return settings; + } + public void Save() { if (_settings != null) diff --git a/src/Commands/Properties/Resources.Designer.cs b/src/Commands/Properties/Resources.Designer.cs index 3357873de5..1d160ac002 100644 --- a/src/Commands/Properties/Resources.Designer.cs +++ b/src/Commands/Properties/Resources.Designer.cs @@ -60,6 +60,87 @@ internal Resources() { } } + /// + /// Looks up a localized string describing an app-only token cache that could not be cleared. + /// + internal static string PersistedLoginCacheNotCleared { + get { + return ResourceManager.GetString("PersistedLoginCacheNotCleared", resourceCulture); + } + } + + /// + /// Looks up a localized string describing a failed persisted login cleanup and how to retry. + /// + internal static string PersistedLoginClearFailed { + get { + return ResourceManager.GetString("PersistedLoginClearFailed", resourceCulture); + } + } + + /// + /// Looks up a localized warning for a connection without a persisted login registration. + /// + internal static string PersistedLoginNotFound { + get { + return ResourceManager.GetString("PersistedLoginNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized warning for token data left after a registration could not be saved. + /// + internal static string PersistedLoginRollbackFailed { + get { + return ResourceManager.GetString("PersistedLoginRollbackFailed", resourceCulture); + } + } + + /// + /// Looks up a localized error for unavailable secure app-only token storage. + /// + internal static string PersistedLoginSecureStorageUnavailable { + get { + return ResourceManager.GetString("PersistedLoginSecureStorageUnavailable", resourceCulture); + } + } + + /// + /// Looks up a localized warning for connecting without unavailable secure token storage. + /// + internal static string PersistedLoginSecureStorageWarning { + get { + return ResourceManager.GetString("PersistedLoginSecureStorageWarning", resourceCulture); + } + } + + /// + /// Looks up a localized error for invalid persisted login settings. + /// + internal static string PersistedLoginSettingsInvalid { + get { + return ResourceManager.GetString("PersistedLoginSettingsInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized error for an explicit persistence request with unreadable settings. + /// + internal static string PersistedLoginSettingsUnavailable { + get { + return ResourceManager.GetString("PersistedLoginSettingsUnavailable", resourceCulture); + } + } + + /// + /// Looks up a localized warning for continuing without unreadable persisted login settings. + /// + internal static string PersistedLoginSettingsWarning { + get { + return ResourceManager.GetString("PersistedLoginSettingsWarning", resourceCulture); + } + } + /// /// Looks up a localized string similar to No URL specified nor does the provided access token contain an audience. /// diff --git a/src/Commands/Properties/Resources.resx b/src/Commands/Properties/Resources.resx index b6bd1fa107..a521651be2 100644 --- a/src/Commands/Properties/Resources.resx +++ b/src/Commands/Properties/Resources.resx @@ -411,4 +411,31 @@ The provided container could not be found + + The persisted app-only token cache still contains data after clearing it. + + + Unable to clear the persisted login. The connection has been retained. For an app-only connection, its registration has also been retained. Restore access to secure storage and retry Disconnect-PnPOnline -ClearPersistedLogin. + + + No persisted login was found for {0} with client ID {1}, so no persisted login registration was removed. Use Get-PnPPersistedLogin to list the registered persisted logins. + + + Saving the persisted login registration failed, and its app-only token cache could not be removed: {0}. Token data may remain in secure storage. + + + Secure persistence for the app-only token cache is unavailable on this machine, so the persisted app-only login cache cannot be initialized or reused. + + + Secure persistence for the app-only token cache is unavailable. Connecting without the persisted login cache, so this token will not be reused across sessions. The existing registration and any previously stored tokens have not been removed. Restore access to secure storage before retrying with -PersistLogin or running Disconnect-PnPOnline -ClearPersistedLogin. + + + The persisted login settings must contain a settings object without null cache registrations. + + + Unable to read the persisted login settings. Repair the settings file or restore read access before connecting with -PersistLogin. No token has been acquired or written by this connection attempt. + + + Unable to read the persisted login settings: {0} Connecting without the persisted login cache. Existing settings and cached tokens have not been changed. Repair the settings file or restore read access before using persisted login. + \ No newline at end of file diff --git a/src/Commands/Utilities/AppOnlyTokenCache.cs b/src/Commands/Utilities/AppOnlyTokenCache.cs new file mode 100644 index 0000000000..7d23abfd11 --- /dev/null +++ b/src/Commands/Utilities/AppOnlyTokenCache.cs @@ -0,0 +1,129 @@ +using System.IO; +using System.Security.Cryptography; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Extensions.Msal; +using PnP.PowerShell.Commands.Properties; + +namespace PnP.PowerShell.Commands.Utilities +{ + /// + /// Persists an app-only MSAL cache using secure storage, propagating storage failures to the caller. + /// + internal sealed class AppOnlyTokenCache + { + private readonly StorageCreationProperties _properties; + private readonly Storage _storage; + private CrossPlatLock _cacheLock; + + internal AppOnlyTokenCache(StorageCreationProperties properties) + { + _properties = properties; + _storage = Storage.Create(properties); + } + + /// Verifies that secure storage is available before attaching the cache. + internal void VerifyPersistence() + { + using var cacheLock = CreateLock(); + _storage.VerifyPersistence(); + } + + /// Registers synchronous callbacks so token acquisition observes storage failures. + internal void RegisterCache(ITokenCache tokenCache) + { + tokenCache.SetBeforeAccess(BeforeAccess); + tokenCache.SetAfterAccess(AfterAccess); + } + + /// Clears the isolated app-only cache, failing if its contents cannot be removed. + internal void Clear() + { + using var cacheLock = CreateLock(); + if (System.OperatingSystem.IsWindows()) + { + File.Delete(_properties.CacheFilePath); + } + else + { + _storage.Clear(ignoreExceptions: false); + } + if (ReadData().Length != 0) + { + throw new IOException(Resources.PersistedLoginCacheNotCleared); + } + } + + private CrossPlatLock CreateLock() + { + return new CrossPlatLock(_properties.CacheFilePath + ".lockfile", _properties.LockRetryDelay, _properties.LockRetryCount); + } + + private byte[] ReadData() + { + if (!System.OperatingSystem.IsWindows()) + { + return _storage.ReadData(); + } + + // MSAL's Windows file accessor suppresses I/O failures; use the same DPAPI format without suppressing errors. + byte[] data; + try + { + data = File.ReadAllBytes(_properties.CacheFilePath); + } + catch (FileNotFoundException) + { + return []; + } + return data.Length == 0 ? data : ProtectedData.Unprotect(data, null, DataProtectionScope.CurrentUser); + } + + private void WriteData(byte[] data) + { + if (System.OperatingSystem.IsWindows()) + { + File.WriteAllBytes(_properties.CacheFilePath, ProtectedData.Protect(data, null, DataProtectionScope.CurrentUser)); + } + else + { + _storage.WriteData(data); + } + } + + private void BeforeAccess(TokenCacheNotificationArgs args) + { + // Keep MSAL's read/modify/write cycle under the same cross-process lock. + _cacheLock = CreateLock(); + try + { + args.TokenCache.DeserializeMsalV3(ReadData(), shouldClearExistingCache: true); + } + catch + { + ReleaseLock(); + throw; + } + } + + private void AfterAccess(TokenCacheNotificationArgs args) + { + try + { + if (args.HasStateChanged) + { + WriteData(args.TokenCache.SerializeMsalV3()); + } + } + finally + { + ReleaseLock(); + } + } + + private void ReleaseLock() + { + _cacheLock?.Dispose(); + _cacheLock = null; + } + } +}