diff --git a/CHANGELOG.md b/CHANGELOG.md index 35042d5dc..bca7c73d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,12 @@ 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) +- 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/Connect-PnPOnline.md b/documentation/Connect-PnPOnline.md index 97289486a..fa4c7806b 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 @@ -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 ] @@ -56,7 +56,7 @@ Connect-PnPOnline [-ReturnConnection] [-Url] [-CreateDrive] [-DriveName ### 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 ] @@ -94,8 +94,8 @@ Connect-PnPOnline [-Url ] -ManagedIdentity -UserAssignedManagedIdentityA ### Environment Variable ```powershell -Connect-PnPOnline [-ReturnConnection] [-Url] [-EnvironmentVariable] [-CurrentCredentials] - [-CreateDrive] [-DriveName ] [-RedirectUri ] +Connect-PnPOnline [-ReturnConnection] [-Url] -EnvironmentVariable [-PersistLogin] + [-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 @@ -536,13 +536,25 @@ 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. + +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. ```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 @@ -600,7 +612,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 @@ -610,6 +622,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. @@ -800,7 +824,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/Disconnect-PnPOnline.md b/documentation/Disconnect-PnPOnline.md index c237933f9..923b7e648 100644 --- a/documentation/Disconnect-PnPOnline.md +++ b/documentation/Disconnect-PnPOnline.md @@ -33,14 +33,23 @@ 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 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 -Parameter Sets: (All)) +Parameter Sets: (All) Aliases: Required: False @@ -52,4 +61,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/documentation/Get-PnPPersistedLogin.md b/documentation/Get-PnPPersistedLogin.md new file mode 100644 index 000000000..e0cf5bf6d --- /dev/null +++ b/documentation/Get-PnPPersistedLogin.md @@ -0,0 +1,55 @@ +--- +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 [-Verbose] +``` + +## 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. + +## 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) + +[Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) diff --git a/pages/articles/credentialmanagement.md b/pages/articles/credentialmanagement.md index 224b5334e..c8bf5d0bc 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 -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/pages/articles/persistedlogin.md b/pages/articles/persistedlogin.md index 8853c0199..a63e6a3c6 100644 --- a/pages/articles/persistedlogin.md +++ b/pages/articles/persistedlogin.md @@ -1,31 +1,51 @@ # 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. +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. ## 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. + +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 + +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`. +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? +### 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 5d77fc363..0f16666c0 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 && + 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 && + 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 && + 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, "", "", ""); @@ -753,7 +763,7 @@ private PnPConnection ConnectCredentials(PSCredential credentials, Initializatio PersistLogin, AzureEnvironment, ClientId, - RedirectUri, TransformationOnPrem, initializationType); + RedirectUri, TransformationOnPrem, initializationType, ErrorActionSetting); } @@ -853,7 +863,8 @@ private PnPConnection ConnectEnvironmentVariable(InitializationType initializati } X509Certificate2 certificate = CertificateHelper.GetCertificateFromPath(this, azureCertificatePath, secPassword, X509KeyStorageFlags); - if (Connection?.ClientId == azureClientId && + if (!PersistLogin && + 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)) @@ -893,7 +904,7 @@ private PnPConnection ConnectEnvironmentVariable(InitializationType initializati PersistLogin, AzureEnvironment, azureClientId, - RedirectUri, TransformationOnPrem, initializationType); + RedirectUri, TransformationOnPrem, initializationType, ErrorActionSetting); } return null; @@ -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); @@ -1090,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 545c23640..3e873dda2 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); - } - PnPConnection.Current = null; var provider = SessionState.Provider.GetAll().FirstOrDefault(p => p.Name.Equals(SPOProvider.PSProviderName, StringComparison.InvariantCultureIgnoreCase)); diff --git a/src/Commands/Base/GetPersistedLogin.cs b/src/Commands/Base/GetPersistedLogin.cs new file mode 100644 index 000000000..6f24e0761 --- /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 baa87584f..375a6c130 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,6 +153,8 @@ internal PnPContext PnPContext internal PnP.Framework.AuthenticationManager AuthenticationManager { get; set; } + internal AppOnlyTokenCache PersistedAppOnlyTokenCache { get; set; } + private string _graphEndPoint; /// private static readonly string[] errorActionSourceArray = ["stop", "ignore", "silentlycontinue"]; @@ -351,21 +354,72 @@ 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) { + var cacheEnabled = ResolveAppOnlyCacheUsage(cmdlet, url, persistLogin, () => CacheEnabled(url.ToString(), clientId, true)); + if (cacheEnabled && !errorActionSourceArray.Contains(ErrorActionSetting.ToLowerInvariant())) + { + WriteCacheEnabledMessage(cmdlet); + } + + AppOnlyTokenCache cacheHelper = null; + Action tokenCacheCallback = null; + if (cacheEnabled) + { + tokenCacheCallback = tokenCache => + { + try + { + var appOnlyCache = new AppOnlyTokenCache(GetTokenCacheStorageProperties(url.ToString(), clientId, appOnly: true)); + appOnlyCache.VerifyPersistence(); + appOnlyCache.RegisterCache(tokenCache); + cacheHelper = appOnlyCache; + } + catch (MsalCachePersistenceException ex) + { + 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 { - authManager = Framework.AuthenticationManager.CreateWithCertificate(clientId, certificate, tenant, azureEnvironment: azureEnvironment); + CachedAuthenticationManager = null; + authManager = Framework.AuthenticationManager.CreateWithCertificate(clientId, certificate, tenant, azureEnvironment: azureEnvironment, tokenCacheCallback: tokenCacheCallback); } + using (authManager) { var clientContext = authManager.GetContext(url.ToString()); + if (persistLogin) + { + try + { + EnableCaching(url.ToString(), clientId, true); + } + catch + { + try + { + cacheHelper?.Clear(); + } + catch (Exception ex) + { + cmdlet.WriteWarning(string.Format(System.Globalization.CultureInfo.CurrentCulture, Resources.PersistedLoginRollbackFailed, ex.Message)); + } + throw; + } + } var context = PnPClientContext.ConvertFrom(clientContext); context.ExecutingWebRequest += (sender, e) => { @@ -385,7 +439,9 @@ internal static PnPConnection CreateWithCert(Uri url, string clientId, string te Certificate = certificate, Tenant = tenant, DeleteCertificateFromCacheOnDisconnect = certificateFromFile, - AzureEnvironment = azureEnvironment + AzureEnvironment = azureEnvironment, + AuthenticationManager = authManager, + PersistedAppOnlyTokenCache = cacheHelper }; return spoConnection; } @@ -494,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) @@ -1094,106 +1150,183 @@ internal static void CleanupCryptoMachineKey(X509Certificate2 certificate) } } - private static async Task MSALCacheHelper(ITokenCache tokenCache, string url, string clientid) + 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 (CacheEnabled(url, clientid)) + try { - try + // 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) { - 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); + cmdlet.ThrowTerminatingError(new ErrorRecord(new InvalidOperationException(Resources.PersistedLoginSettingsUnavailable, ex), "PersistedLoginSettingsUnavailable", ErrorCategory.ReadError, url)); } - catch (MsalCachePersistenceException) - { - PnP.Framework.Diagnostics.Log.Debug("PnPConnection", "Cache persistence failed. Trying again."); - var storage = - new StorageCreationPropertiesBuilder("pnp.msal.cache", cacheDir) + 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"; + + return new StorageCreationPropertiesBuilder(cacheFileName, cacheDir) .WithMacKeyChain( serviceName: $"{CacheSchemaName}.service", - accountName: $"{CacheSchemaName}.account") - .WithLinuxUnprotectedFile() + 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")) .Build(); - var cacheHelper = await MsalCacheHelper.CreateAsync(storage).ConfigureAwait(false); + } - cacheHelper.RegisterCache(tokenCache); - } + 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) + { + PnP.Framework.Diagnostics.Log.Debug("PnPConnection", "Cache persistence failed. Retrying with an unprotected Linux fallback for delegated logins."); + var fallbackStorage = + new StorageCreationPropertiesBuilder(storage.CacheFileName, storage.CacheDirectory) + .WithMacKeyChain( + serviceName: storage.MacKeyChainServiceName, + accountName: storage.MacKeyChainAccountName) + .WithLinuxUnprotectedFile() + .Build(); + var cacheHelper = await MsalCacheHelper.CreateAsync(fallbackStorage).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); - if (entry != null) + 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?.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 { - 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 }); + 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) @@ -1201,15 +1334,51 @@ 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 urls = GetCheckUrls(connection.Url); - var entry = Settings.Current.Cache?.FirstOrDefault(c => urls.Contains(c.Url) && c.ClientId == connection.ClientId); + var appOnly = connection.ConnectionMethod == ConnectionMethod.AzureADAppOnly; + 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) + { + if (entry == null && connection.PersistedAppOnlyTokenCache == null) + { + 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); + try + { + Settings.Current.Save(); + } + catch + { + Settings.Current.Cache.Insert(index, entry); + throw; + } + } + 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/Settings.cs b/src/Commands/Model/Settings.cs index d2da7eaff..59daecdc5 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/Model/TokenCacheConfiguration.cs b/src/Commands/Model/TokenCacheConfiguration.cs index 495f4de85..7eb260dd4 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 diff --git a/src/Commands/Properties/Resources.Designer.cs b/src/Commands/Properties/Resources.Designer.cs index 3357873de..1d160ac00 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 b6bd1fa10..a521651be 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 000000000..7d23abfd1 --- /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; + } + } +}