pnp/cli-microsoft365

Add the Azure CLI login authentication type for better integration with CICD pipelines

Opened this issue Β· 16 comments

Introducing the Azure CLI as a new authentication option would simplify and improve the security of using the M365 CLI in CICD pipelines as both Azure DevOps and GitHub Actions support Microsoft Entra Workload ID. This brings keyless authentication with Entra-secured endpoints.

One of the simplest approaches to use it is to obtain an access token using the Azure CLI az account get-access-token command in a CICD pipeline.

Looking at src/Auth.ts and src/m365/commands/login.ts I can see that it could be easily implemented with just a few lines of code.

// src/Auth.ts

import { spawnSync } from "child_process";

// ...

export enum AuthType {
  DeviceCode,
  Password,
  Certificate,
  Identity,
  Browser,
  Secret,
  AzCli
}

// ...
// Omitted the setup in the ensureAccessToken method
// ...

private async ensureAccessTokenWithAzCli(resource: string, logger: Logger, debug: boolean): Promise<AccessToken | null> {
    if (debug) {
        await logger.logToStderr('Will try to retrieve access token using Azure CLI...');
    }

    const result = spawnSync('az', ['account','get-access-token',`--resource=${resource}`], { encoding: 'utf8' });

    if (result.error) {
        if ((result.error as any).code === 'ENOENT'){
            return Promise.reject('Error while logging with Azure CLI. Please check if Azure CLI is installed.');
        }
        throw result.error;
    }
    
    if (result.stderr) {
        return Promise.reject(result.stderr.trim());
    }

    const authResponse = JSON.parse(result.stdout.trim()) as AccessToken;

    return {
        accessToken: authResponse.accessToken,
        expiresOn: new Date(authResponse.expiresOn)
    }
}

What do you think about that? I would be happy to open a PR and add this functionality πŸ˜„ And last but not least, thank you all for the amazing job that you are doing here πŸ’ͺ

Thank you @rithala for this improvement and seems like a really solid idea!
TBH I don't have much experience in this particular auth method so before we open this up and assign it to you πŸ˜‰ let's wait for some other @pnp/cli-for-microsoft-365-maintainers to have a check on the πŸ‘

One problem I already see in this implementation is that it assumes the user will have Azure CLI installed and CLI uses it under the hood

 const result = spawnSync('az', ['account','get-access-token',`--resource=${resource}`], { encoding: 'utf8' });

Usually we don't do that as we may not gurantee the dependand tooling is present and works everywhere and with CLI this is also what we aim, any shell any device support.
What we could do is:

  • extend the authType option in the m365 login command with something like azureCli (maybe we could set a better name)
  • add a new option token which is only valid and requried when the new --authType azureCli will be used, which will allow the user to pass the token from the Azure CLI get-access-token command.

That way we do not add this tool as a kinda dependency of our tool and we would still support this type of auth method.
@rithala what do you think about that?

@pnp/cli-for-microsoft-365-maintainers any other feed?

Thank you @Adam-it for taking the time to review it. I considered a token option as it is in PnP PowerShell. However, in M365 Cli there might be a command that uses both Graph and SharePoint REST or two preceding commands that use different APIs that need different access tokens. That would require executing m365 login before each command.
Additionally, adding something like a token option to all commands is much work.

The authentication mechanism in the CLI is well-designed and handles constantly changing resources with ease. So that, adding another source for obtaining a token is very easy.

The Azure CLI follows the same principle and runs on all OSes. Additionally, it is available out-of-the-box in ADO Pipelines, GitHub Actions, and Azure Cloud Shell. Here is a check if Azure CLI is installed or not, so a user will be notified when Azure CLI is not available:

 if ((result.error as any).code === 'ENOENT'){
            return Promise.reject('Error while logging with Azure CLI. Please check if Azure CLI is installed.');
        }

The dependency is optional. You only need Azure CLI when you want to use this kind of authentication. Probably, you never want it locally, but for cases like running in the CLI in a pipeline.

Let's not forget CLI for M365 is any shell. Not just PowerShell.

Also if what we target is only a new authentication mode for pipelines rather then having it to use it locally maybe it should only be part of CLI GH actions πŸ€”.

Also I didn't mean to add token option to every command but rather only to the login command and let CLI do the rest under the hood.

Azure CLI is shell-agnostic as well. It can be a part of CLI GH actions, but then it is not available in Azure DevOps. I guess, more enterprises still favour ADO over GH Actions.

Azure CLI is shell-agnostic as well. It can be a part of CLI GH actions, but then it is not available in Azure DevOps. I guess, more enterprises still favour ADO over GH Actions.

That's true, we are working on adding ADO pipeline extensions for CLI but it is not done yet.

@pnp/cli-for-microsoft-365-maintainers any other feed on that?

I think we shouldn't explicitly implement logging in using Azure CLI and instead look into @azure/identity and its DefaultAzureCredential. It abstracts away dependencies and introduces support for other credentials such as VS and VSCode.

@waldekmastykarz good idea, that is even better as it would introduce many more authentication options and standardize existing ones. The login command would create the ChainedTokenCredential based on the provided parameters. It would be quite a big change. I would be happy to help you with that.

Before we proceed with implementation, one thing that we need to check is what it means for the ability to use different commands. If users can't use all commands, we need to know the limitations to properly caveat them in the docs.

I did some tests using @azure/identity with developer tools authentication options. Here are my observations:

  • Using the 1st party service principals results in very limited permission grants. They can be expanded with oauth2PermissionGrants but added scopes are not included in the token.
  • Example of Graph's delegated scopes for Azure CLI: AuditLog.Read.All Directory.AccessAsUser.All email Group.ReadWrite.All openid profile User.ReadWrite.All.
  • The only viable option is to authenticate in a tool with a custom service principal. That does not make sense as you can do this already in the CLI. The exceptions are AzurePowerShellCredential and AzureCliCredential as they can be authenticated by an ADO or GH service connection allowing using underlying service principal in the CLI.
  • VisualStudioCodeCredential is very buggy. I struggled a lot to make it work.
  • @azure/identity can unify existing methods obtaining an access token like a: device code, interactive, client credentials, and managed identity.
  • Advanced token caching with @azure/identity-cache-persistence.

All things considered, there is very little value in adding other developer tools authentication than Azure CLI and Azure PowerShell. Rewriting all the authentication methods to use @azure/identity would require solid regression testing. I would start with adding a new authentication type like azureDevTools which would use the Azure CLI first and then Azure PowerShell. The next step would be replacing the current implementation of the managed identity authentication with ManagedIdentityCredential. Then we can proceed with replacing implementations one by one. What do you think?

Thanks for the research and sharing your findings.

  • @azure/identity can unify existing methods obtaining an access token like a: device code, interactive, client credentials, and managed identity.

I don't think we'll be using this approach, as we ask our users to explicitly specify how they want to connect, rather than using a chained credential like @azure/identity does.

We already implement caching on top of MSAL.

The next step would be replacing the current implementation of the managed identity authentication with ManagedIdentityCredential

What would be the benefit of doing this?

For integration with CI/CD pipelines, I think that folks are likely to use the existing auth mechanisms such as the azure/login@v2 GitHub Action or its equivalent for Azure Pipelines. Have you by any chance looked into which credentials we'd need to implement to use the token from these actions?

@azure/identity does not provide only chained credentials but is an abstraction on top of MSAL simplifying authentication. You can use credentials like DeviceCodeCredential, UsernamePasswordCredential or ClientCertificateCredential. For example InteractiveBrowserCredential listens for a response and there is no need to implement something like AuthServer.ts.

I saw that the CLI caches tokens. @azure/identity-cache-persistence brings additional security as it can cache in KeyRing, KeyChaing, and an encrypted file.

The current implementation of the managed identity authentication is good. But it is a big chunk of code with the manual implementation that can be replaced with two lines of code when using ManagedIdentityCredential.

For pipelines Azure CLI would be the best option. Using the provided service connection, you can run M365 CLI scripts in Azure CLI actions. I see that they added AzurePipelinesCredential. I have no experience working with this. From the description, I can assume that it is specific to ADO and you need to provide much more information. So again I would bet on AzureCliCredential. I use in my projects with PnP PowerShell and it is very convenient.

Here is an example.

- task: AzureCLI@2
   displayName: Deploy SPFx
   inputs:
    scriptType: pscore
    scriptLocation: inlineScript
    azureSubscription: My Service Connection
    inlineScript: |
     $siteUri = [System.Uri]::new("$(SiteUrl)")
     $resource = "https://" + $siteUri.Host
     $token = az account get-access-token --resource=$resource --query accessToken --output tsv
     Connect-PnPOnline -Url "$(SiteUrl)" -AccessToken $token

How it might look like with M365 CLI?

- task: AzureCLI@2
   displayName: Deploy SPFx
   inputs:
    scriptType: bash
    scriptLocation: inlineScript
    azureSubscription: My Service Connection
    inlineScript: |
     m365 login --authType azureCli

Thanks for sharing some more info about the @azure/identity lib. It would be interesting to check if what it offers is in full feature parity with what we've got. If it's the same, I agree that we should look into replacing our custom code with the implementation from the lib.

For pipelines Azure CLI would be the best option. Using the provided service connection, you can run M365 CLI scripts in Azure CLI actions.

Rather than implementing just the AZ CLI, let's also consider azd and Azure PowerShell in case folks use these tools instead. To me, they're equivalent and we should support all of them. I'm not quite sure if --authType azureCli clearly communicates that we're supporting all Azure command-line tools or if it's too close to Azure CLI. At the moment, I don't have a better alternative as for the name, so let's give it a thought.

Going back to your proposed implementation, I suggest we don't call az CLI ourselves, but rather use AzureCliCredential and let it handle figuring out where to get the token from.

I agree, it is good idea to support more that just Azure CLI and let users use other dev tools.

You are right, the implementation should use the lib instead of spawning process and calling the CLI directly.

I totally forgot that @azure/identity supports authentication with developer tools. I have been using it for couple years in .NET but always with standard OAuth flows. Probably, that is why I have not considered it. Thank you Waldek for pointing this out.

Another option for the name could be β€”authType azureDevTools. It is a bit odd and long but well describes what is underneath.

I wonder if azureDevTools doesn't set the expectation that we're also supporting VSCode and VS. What if we used azureClis (plural) to indicate multiple command-line tools rather than az cli specifically?

Not ideal name but it looks like there is no good one. Let it be azureClis then.

Are you ok with me opening a PR just for the CLIs? After that we can have another discussion on replacing authentication implementations with @azure/identity.