diff --git a/.github/workflows/azure-login-negative.yml b/.github/workflows/azure-login-negative.yml index 42e2e3bc4..cd2c57dcb 100644 --- a/.github/workflows/azure-login-negative.yml +++ b/.github/workflows/azure-login-negative.yml @@ -356,4 +356,74 @@ jobs: uses: actions/github-script@v3 with: script: | - core.setFailed('Last action should fail but not. Please check it.') \ No newline at end of file + core.setFailed('Last action should fail but not. Please check it.') + + - name: Login with tenant-level account, without allow-no-subscriptions + id: login_11 + continue-on-error: true + uses: ./ + with: + client-id: ${{ secrets.OIDC_SP2_CLIENT_ID }} + tenant-id: ${{ secrets.OIDC_SP2_TENANT_ID }} + subscription-id: ${{ secrets.OIDC_SP2_SUBSCRIPTION_ID }} + enable-AzPSSession: true + + - name: Check Last step failed + if: steps.login_11.outcome == 'success' + uses: actions/github-script@v3 + with: + script: | + core.setFailed('Last action should fail but not. Please check it.') + + # Secret of SP1 in creds will be used to sign in SP2 + - name: Login with both creds and individual parameters + id: login_12 + continue-on-error: true + uses: ./ + with: + creds: ${{secrets.SP1}} + client-id: ${{ secrets.OIDC_SP2_CLIENT_ID }} + tenant-id: ${{ secrets.OIDC_SP2_TENANT_ID }} + subscription-id: ${{ secrets.OIDC_SP2_SUBSCRIPTION_ID }} + allow-no-subscriptions: true + enable-AzPSSession: true + + - name: Check Last step failed + if: steps.login_12.outcome == 'success' + uses: actions/github-script@v3 + with: + script: | + core.setFailed('Last action should fail but not. Please check it.') + + VMTest: + strategy: + matrix: + os: [self_linux, self_windows] + runs-on: ${{ matrix.os }} + environment: Automation test + + steps: + - name: 'Checking out repo code' + uses: actions/checkout@v3.5.2 + + - name: Set Node.js 16.x for GitHub Action + uses: actions/setup-node@v1 + with: + node-version: 16.x + + - name: 'Validate build' + run: | + npm install + npm run build + + - name: Login with system-assigned managed identity without auth-type + id: login_13 + continue-on-error: true + uses: ./ + + - name: Check Last step failed + if: steps.login_13.outcome == 'success' + uses: actions/github-script@v3 + with: + script: | + core.setFailed('Last action should fail but not. Please check it.') diff --git a/.github/workflows/azure-login-positive.yml b/.github/workflows/azure-login-positive.yml index a2a1b04dd..cd4a47f54 100644 --- a/.github/workflows/azure-login-positive.yml +++ b/.github/workflows/azure-login-positive.yml @@ -75,6 +75,28 @@ jobs: inlineScript: | Get-AzContext | Format-List + - name: Login with explicit auth-type + uses: ./ + with: + creds: ${{secrets.SP1}} + auth-type: SERVICE_PRINCIPAL + enable-AzPSSession: true + + - name: Run Azure Cli + run: | + az account show + az group show --name GitHubActionGroup + az vm list + + - name: Run Azure PowerShell + uses: azure/powershell@v1.2.0 + with: + azPSVersion: "latest" + inlineScript: | + Get-AzContext | Format-List + Get-AzResourceGroup -Name GitHubActionGroup + Get-AzVM + ParameterTest: strategy: matrix: @@ -96,35 +118,23 @@ jobs: npm install npm run build - - name: Login with both creds and individual parameters + - name: Login with creds, disable ps session uses: ./ with: creds: ${{secrets.SP1}} - client-id: ${{ secrets.OIDC_SP2_CLIENT_ID }} - tenant-id: ${{ secrets.OIDC_SP2_TENANT_ID }} - subscription-id: ${{ secrets.OIDC_SP2_SUBSCRIPTION_ID }} - enable-AzPSSession: true + enable-AzPSSession: false - - name: Run Azure Cli + - name: Run Azure Cli run: | az account show az group show --name GitHubActionGroup az vm list - - name: Run Azure PowerShell - uses: azure/powershell@v1.2.0 - with: - azPSVersion: "latest" - inlineScript: | - Get-AzContext | Format-List - Get-AzResourceGroup -Name GitHubActionGroup - Get-AzVM - - - name: Login with creds, disable ps session + - name: Login with creds, wrong boolean value uses: ./ with: creds: ${{secrets.SP1}} - enable-AzPSSession: false + enable-AzPSSession: notboolean - name: Run Azure Cli run: | @@ -132,17 +142,23 @@ jobs: az group show --name GitHubActionGroup az vm list - - name: Login with creds, wrong boolean value + - name: Login by OIDC with all info in creds uses: ./ with: - creds: ${{secrets.SP1}} - enable-AzPSSession: notboolean + creds: ${{secrets.SP2}} + allow-no-subscriptions: true + enable-AzPSSession: true - - name: Run Azure Cli + - name: Run Azure Cli run: | az account show - az group show --name GitHubActionGroup - az vm list + + - name: Run Azure PowerShell + uses: azure/powershell@v1.2.0 + with: + azPSVersion: "latest" + inlineScript: | + Get-AzContext | Format-List - name: Login with creds, allow no subscription uses: ./ @@ -185,3 +201,96 @@ jobs: inlineScript: | Get-AzContext | Format-List + VMTest: + strategy: + matrix: + os: [self_linux, self_windows] + runs-on: ${{ matrix.os }} + environment: Automation test + + steps: + - name: 'Checking out repo code' + uses: actions/checkout@v3.5.2 + + - name: Set Node.js 16.x for GitHub Action + uses: actions/setup-node@v1 + with: + node-version: 16.x + + - name: 'Validate build' + run: | + npm install + npm run build + + - name: Login with system-assigned managed identity + uses: ./ + with: + auth-type: IDENTITY + # enable-AzPSSession: true + + - name: Run Azure Cli + run: | + az account show + + # - name: Run Azure PowerShell + # uses: azure/powershell@v1.2.0 + # with: + # azPSVersion: "latest" + # inlineScript: | + # Get-AzContext | Format-List + + - name: Login with user-assigned managed identity + uses: ./ + with: + client-id: ${{ secrets.UMI1_CLIENT_ID }} + auth-type: IDENTITY + # enable-AzPSSession: true + + - name: Run Azure Cli + run: | + az account show + + # - name: Run Azure PowerShell + # uses: azure/powershell@v1.2.0 + # with: + # azPSVersion: "latest" + # inlineScript: | + # Get-AzContext | Format-List + + - name: Login with user-assigned managed identity, subscription-id + uses: ./ + with: + client-id: ${{ secrets.UMI1_CLIENT_ID }} + subscription-id: ${{ secrets.UMI1_SUBSCRIPTION_ID }} + auth-type: IDENTITY + # enable-AzPSSession: true + + - name: Run Azure Cli + run: | + az account show + + # - name: Run Azure PowerShell + # uses: azure/powershell@v1.2.0 + # with: + # azPSVersion: "latest" + # inlineScript: | + # Get-AzContext | Format-List + + - name: Login with tenant-level user-assigned managed identity with allow-no-subscriptions + uses: ./ + with: + client-id: ${{ secrets.UMI2_CLIENT_ID }} + allow-no-subscriptions: true + auth-type: IDENTITY + # enable-AzPSSession: true + + - name: Run Azure Cli + run: | + az account show + + # - name: Run Azure PowerShell + # uses: azure/powershell@v1.2.0 + # with: + # azPSVersion: "latest" + # inlineScript: | + # Get-AzContext | Format-List diff --git a/action.yml b/action.yml index c9883bb8b..e3eea6e71 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ # Login to Azure subscription name: 'Azure Login' -description: 'Authenticate to Azure using OIDC and run your Az CLI or Az PowerShell based actions or scripts. github.com/Azure/Actions' +description: 'Authenticate to Azure using OIDC and run your Azure CLI or Azure PowerShell based actions or scripts. github.com/Azure/Actions' inputs: creds: description: 'Paste output of `az ad sp create-for-rbac` as value of secret variable: AZURE_CREDENTIALS' @@ -15,7 +15,7 @@ inputs: description: 'Azure subscriptionId' required: false enable-AzPSSession: - description: 'Set this value to true to enable Azure PowerShell Login in addition to Az CLI login' + description: 'Set this value to true to enable Azure PowerShell Login in addition to Azure CLI login' required: false default: false environment: @@ -30,6 +30,10 @@ inputs: description: 'Provide audience field for access-token. Default value is api://AzureADTokenExchange' required: false default: 'api://AzureADTokenExchange' + auth-type: + description: 'The type of authentication. Supported values are SERVICE_PRINCIPAL, IDENTITY. Default value is SERVICE_PRINCIPAL' + required: false + default: 'SERVICE_PRINCIPAL' branding: icon: 'login.svg' color: 'blue' diff --git a/src/Cli/AzureCliLogin.ts b/src/Cli/AzureCliLogin.ts index adf86efe2..f702d4d1e 100644 --- a/src/Cli/AzureCliLogin.ts +++ b/src/Cli/AzureCliLogin.ts @@ -7,14 +7,20 @@ import * as io from '@actions/io'; export class AzureCliLogin { loginConfig: LoginConfig; azPath: string; - + loginOptions: ExecOptions; + constructor(loginConfig: LoginConfig) { this.loginConfig = loginConfig; + this.loginOptions = defaultExecOptions(); } async login() { + console.log(`Running Azure CLI Login`); this.azPath = await io.which("az", true); - core.debug(`az cli path: ${this.azPath}`); + if (!this.azPath) { + throw new Error("Azure CLI is not found in the runner."); + } + core.debug(`Azure CLI path: ${this.azPath}`); let output: string = ""; const execOptions: any = { @@ -24,39 +30,35 @@ export class AzureCliLogin { } } }; - await this.executeAzCliCommand("--version", true, execOptions); - core.debug(`az cli version used:\n${output}`); + + await this.executeAzCliCommand(["--version"], true, execOptions); + core.debug(`Azure CLI version used:\n${output}`); this.setAzurestackEnvIfNecessary(); - await this.executeAzCliCommand(`cloud set -n "${this.loginConfig.environment}"`, false); + await this.executeAzCliCommand(["cloud", "set", "-n", this.loginConfig.environment], false); console.log(`Done setting cloud: "${this.loginConfig.environment}"`); - // Attempting Az cli login - var commonArgs = ["--service-principal", - "-u", this.loginConfig.servicePrincipalId, - "--tenant", this.loginConfig.tenantId - ]; - if (this.loginConfig.allowNoSubscriptionsLogin) { - commonArgs = commonArgs.concat("--allow-no-subscriptions"); - } - if (this.loginConfig.enableOIDC) { - commonArgs = commonArgs.concat("--federated-token", this.loginConfig.federatedToken); + if (this.loginConfig.authType == "service_principal") { + let args = ["--service-principal", + "--username", this.loginConfig.servicePrincipalId, + "--tenant", this.loginConfig.tenantId + ]; + if (this.loginConfig.servicePrincipalKey) { + await this.loginWithSecret(args); + } + else { + await this.loginWithOIDC(args); + } } else { - console.log("Note: Azure/login action also supports OIDC login mechanism. Refer https://github.com/azure/login#configure-a-service-principal-with-a-federated-credential-to-use-oidc-based-authentication for more details.") - commonArgs = commonArgs.concat(`--password=${this.loginConfig.servicePrincipalKey}`); - } - - const loginOptions: ExecOptions = defaultExecOptions(); - await this.executeAzCliCommand(`login`, true, loginOptions, commonArgs); - - if (!this.loginConfig.allowNoSubscriptionsLogin) { - var args = [ - "--subscription", - this.loginConfig.subscriptionId - ]; - await this.executeAzCliCommand(`account set`, true, loginOptions, args); + let args = ["--identity"]; + if (this.loginConfig.servicePrincipalId) { + await this.loginWithUserAssignedIdentity(args); + } + else { + await this.loginWithSystemAssignedIdentity(args); + } } } @@ -70,8 +72,8 @@ export class AzureCliLogin { console.log(`Unregistering cloud: "${this.loginConfig.environment}" first if it exists`); try { - await this.executeAzCliCommand(`cloud set -n AzureCloud`, true); - await this.executeAzCliCommand(`cloud unregister -n "${this.loginConfig.environment}"`, false); + await this.executeAzCliCommand(["cloud", "set", "-n", "AzureCloud"], true); + await this.executeAzCliCommand(["cloud", "unregister", "-n", this.loginConfig.environment], false); } catch (error) { console.log(`Ignore cloud not registered error: "${error}"`); @@ -86,22 +88,67 @@ export class AzureCliLogin { let suffixKeyvault = ".vault" + baseUri.substring(baseUri.indexOf('.')); // keyvault suffix starts with . let suffixStorage = baseUri.substring(baseUri.indexOf('.') + 1); // storage suffix starts without . let profileVersion = "2019-03-01-hybrid"; - await this.executeAzCliCommand(`cloud register -n "${this.loginConfig.environment}" --endpoint-resource-manager "${this.loginConfig.resourceManagerEndpointUrl}" --suffix-keyvault-dns "${suffixKeyvault}" --suffix-storage-endpoint "${suffixStorage}" --profile "${profileVersion}"`, false); + await this.executeAzCliCommand(["cloud", "register", "-n", this.loginConfig.environment, "--endpoint-resource-manager", `"${this.loginConfig.resourceManagerEndpointUrl}"`, "--suffix-keyvault-dns", `"${suffixKeyvault}"`, "--suffix-storage-endpoint", `"${suffixStorage}"`, "--profile", `"${profileVersion}"`], false); } catch (error) { - core.error(`Error while trying to register cloud "${this.loginConfig.environment}": "${error}"`); + core.error(`Error while trying to register cloud "${this.loginConfig.environment}"`); + throw error; } console.log(`Done registering cloud: "${this.loginConfig.environment}"`) } + async loginWithSecret(args: string[]) { + console.log("Note: Azure/login action also supports OIDC login mechanism. Refer https://github.com/azure/login#configure-a-service-principal-with-a-federated-credential-to-use-oidc-based-authentication for more details.") + args.push(`--password=${this.loginConfig.servicePrincipalKey}`); + await this.callCliLogin(args, 'service principal with secret'); + } + + async loginWithOIDC(args: string[]) { + await this.loginConfig.getFederatedToken(); + args.push("--federated-token", this.loginConfig.federatedToken); + await this.callCliLogin(args, 'OIDC'); + } + + async loginWithUserAssignedIdentity(args: string[]) { + args.push("--username", this.loginConfig.servicePrincipalId); + await this.callCliLogin(args, 'user-assigned managed identity'); + } + + async loginWithSystemAssignedIdentity(args: string[]) { + await this.callCliLogin(args, 'system-assigned managed identity'); + } + + async callCliLogin(args: string[], methodName: string) { + console.log(`Attempting Azure CLI login by using ${methodName}...`); + args.unshift("login"); + if (this.loginConfig.allowNoSubscriptionsLogin) { + args.push("--allow-no-subscriptions"); + } + await this.executeAzCliCommand(args, true, this.loginOptions); + await this.setSubscription(); + console.log(`Azure CLI login succeed by using ${methodName}.`); + } + + async setSubscription() { + if (this.loginConfig.allowNoSubscriptionsLogin) { + return; + } + if (!this.loginConfig.subscriptionId) { + core.warning('No subscription-id is given. Skip setting subscription... If there are mutiple subscriptions under the tenant, please input subscription-id to specify which subscription to use.'); + return; + } + let args = ["account", "set", "--subscription", this.loginConfig.subscriptionId]; + await this.executeAzCliCommand(args, true, this.loginOptions); + console.log("Subscription is set successfully."); + } + async executeAzCliCommand( - command: string, + args: string[], silent?: boolean, - execOptions: any = {}, - args: any = []) { + execOptions: any = {}) { execOptions.silent = !!silent; - await exec.exec(`"${this.azPath}" ${command}`, args, execOptions); + await exec.exec(`"${this.azPath}"`, args, execOptions); } } @@ -119,10 +166,9 @@ function defaultExecOptions(): exec.ExecOptions { //removing the keyword 'ERROR' to avoid duplicates while throwing error error = error.slice(5); } - core.setFailed(error); + core.error(error); } } } }; } - diff --git a/src/common/LoginConfig.ts b/src/common/LoginConfig.ts index 914e53009..1f52dcf56 100644 --- a/src/common/LoginConfig.ts +++ b/src/common/LoginConfig.ts @@ -9,53 +9,49 @@ export class LoginConfig { "azurecloud", "azurestack"]); + static readonly azureSupportedAuthType = new Set([ + "service_principal", + "identity"]); + + authType: string; servicePrincipalId: string; servicePrincipalKey: string; tenantId: string; subscriptionId: string; resourceManagerEndpointUrl: string; allowNoSubscriptionsLogin: boolean; - enableOIDC: boolean; environment: string; enableAzPSSession: boolean; audience: string; federatedToken: string; - constructor() { - this.enableOIDC = true; - } - async initialize() { this.environment = core.getInput("environment").toLowerCase(); this.enableAzPSSession = core.getInput('enable-AzPSSession').toLowerCase() === "true"; this.allowNoSubscriptionsLogin = core.getInput('allow-no-subscriptions').toLowerCase() === "true"; + this.authType = core.getInput('auth-type').toLowerCase(); this.servicePrincipalId = core.getInput('client-id', { required: false }); this.servicePrincipalKey = null; this.tenantId = core.getInput('tenant-id', { required: false }); this.subscriptionId = core.getInput('subscription-id', { required: false }); - this.audience = core.getInput('audience', { required: false }); - this.federatedToken = null; let creds = core.getInput('creds', { required: false }); let secrets = creds ? new SecretParser(creds, FormatType.JSON) : null; - if (creds) { - core.debug('using creds JSON...'); - this.enableOIDC = false; - this.servicePrincipalId = secrets.getSecret("$.clientId", true); - this.servicePrincipalKey = secrets.getSecret("$.clientSecret", true); - this.tenantId = secrets.getSecret("$.tenantId", true); - this.subscriptionId = secrets.getSecret("$.subscriptionId", true); + core.debug('Reading creds in JSON...'); + this.servicePrincipalId = this.servicePrincipalId ? this.servicePrincipalId : secrets.getSecret("$.clientId", false); + this.servicePrincipalKey = secrets.getSecret("$.clientSecret", false); + this.tenantId = this.tenantId ? this.tenantId : secrets.getSecret("$.tenantId", false); + this.subscriptionId = this.subscriptionId ? this.subscriptionId : secrets.getSecret("$.subscriptionId", false); this.resourceManagerEndpointUrl = secrets.getSecret("$.resourceManagerEndpointUrl", false); } - this.getFederatedTokenIfNecessary(); + + this.audience = core.getInput('audience', { required: false }); + this.federatedToken = null; } - async getFederatedTokenIfNecessary() { - if (!this.enableOIDC) { - return; - } + async getFederatedToken() { try { this.federatedToken = await core.getIDToken(this.audience); } @@ -63,24 +59,21 @@ export class LoginConfig { core.error(`Please make sure to give write permissions to id-token in the workflow.`); throw error; } - if (!!this.federatedToken) { - let [issuer, subjectClaim] = await jwtParser(this.federatedToken); - console.log("Federated token details: \n issuer - " + issuer + " \n subject claim - " + subjectClaim); - } - else { - throw new Error("Failed to fetch federated token."); - } + let [issuer, subjectClaim] = await jwtParser(this.federatedToken); + console.log("Federated token details:\n issuer - " + issuer + "\n subject claim - " + subjectClaim); } async validate() { - if (!this.servicePrincipalId || !this.tenantId || !(this.servicePrincipalKey || this.enableOIDC)) { - throw new Error("Not all values are present in the credentials. Ensure clientId, clientSecret and tenantId are supplied."); + if (!LoginConfig.azureSupportedCloudName.has(this.environment)) { + throw new Error("Unsupported value for environment is passed. The list of supported values for environment are 'azureusgovernment', 'azurechinacloud', 'azuregermancloud', 'azurecloud' or 'azurestack'"); } - if (!this.subscriptionId && !this.allowNoSubscriptionsLogin) { - throw new Error("Not all values are present in the credentials. Ensure subscriptionId is supplied."); + if (!LoginConfig.azureSupportedAuthType.has(this.authType)) { + throw new Error("Unsupported value for authentication type is passed. The list of supported values for auth-type are 'SERVICE_PRINCIPAL' or 'IDENTITY'"); } - if (!LoginConfig.azureSupportedCloudName.has(this.environment)) { - throw new Error("Unsupported value for environment is passed.The list of supported values for environment are ‘azureusgovernment', ‘azurechinacloud’, ‘azuregermancloud’, ‘azurecloud’ or ’azurestack’"); + if (this.authType == "service_principal") { + if (!this.servicePrincipalId || !this.tenantId) { + throw new Error("Using auth-type: SERVICE_PRINCIPAL. Not all values are present in the credentials. Ensure clientId and tenantId are supplied."); + } } } } @@ -90,4 +83,4 @@ async function jwtParser(federatedToken: string) { let bufferObj = Buffer.from(tokenPayload, "base64"); let decodedPayload = JSON.parse(bufferObj.toString("utf8")); return [decodedPayload['iss'], decodedPayload['sub']]; -} \ No newline at end of file +} diff --git a/src/main.ts b/src/main.ts index 3848ed4dc..0d07be174 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,6 @@ var prefix = !!process.env.AZURE_HTTP_USER_AGENT ? `${process.env.AZURE_HTTP_USE var azPSHostEnv = !!process.env.AZUREPS_HOST_ENVIRONMENT ? `${process.env.AZUREPS_HOST_ENVIRONMENT}` : ""; async function main() { - var isAzCLISuccess = false; try { let usrAgentRepo = `${process.env.GITHUB_REPOSITORY}`; let actionName = 'AzureLogin'; @@ -16,33 +15,31 @@ async function main() { core.exportVariable('AZURE_HTTP_USER_AGENT', userAgentString); core.exportVariable('AZUREPS_HOST_ENVIRONMENT', azurePSHostEnv); - // perpare the login configuration + // prepare the login configuration var loginConfig = new LoginConfig(); await loginConfig.initialize(); await loginConfig.validate(); - // login to Azure Cli + // login to Azure CLI var cliLogin = new AzureCliLogin(loginConfig); await cliLogin.login(); - isAzCLISuccess = true; //login to Azure PowerShell if (loginConfig.enableAzPSSession) { console.log(`Running Azure PS Login`); + //remove the following 'if session' once the code is ready + if (!loginConfig.servicePrincipalKey) { + await loginConfig.getFederatedToken(); + } var spnlogin: ServicePrincipalLogin = new ServicePrincipalLogin(loginConfig); await spnlogin.initialize(); await spnlogin.login(); } - console.log("Login successful."); } catch (error) { - if (!isAzCLISuccess) { - core.setFailed(`Az CLI Login failed with ${error}. Please check the credentials and make sure az is installed on the runner. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows`); - } - else { - core.setFailed(`Azure PowerShell Login failed with ${error}. Please check the credentials and make sure az is installed on the runner. For more information refer https://aka.ms/create-secrets-for-GitHub-workflows`); - } + core.setFailed(`Login failed with ${error}. Please check the credentials and auth-type, and make sure 'az' is installed on the runner. For more information refer https://github.com/Azure/login#readme.`); + core.debug(error.stack); } finally { // Reset AZURE_HTTP_USER_AGENT