Support Managed Identity (#348)

* support MI for CLI

* support MI for PowerShell

* refactor and fix typo

* adjust logic according to review comments

* create a type for PS Script output and add some test cases

* rename servicePrincipalKey to servicePrincipalSecret
This commit is contained in:
Yan Xu
2023-10-20 16:38:30 +08:00
committed by GitHub
parent a00376ee0f
commit 8bf3bdeba9
21 changed files with 1102 additions and 490 deletions

View File

@@ -26,35 +26,15 @@ jobs:
run: |
npm install
npm run build
- name: 'Run L0 tests'
id: run_test
continue-on-error: true
run: |
npm run test
- name: Check Last step failed
if: steps.run_test.outcome == 'success'
uses: actions/github-script@v3
with:
script: |
core.setFailed('Last action should fail but not. Please check it.')
- name: Login with creds
id: login_1
continue-on-error: true
uses: ./
with:
creds: ${{secrets.SP1}}
enable-AzPSSession: true
- name: Check Last step failed
if: steps.login_1.outcome == 'success'
uses: actions/github-script@v3
with:
script: |
core.setFailed('Last action should fail but not. Please check it.')
- name: Run Azure Cli
run: |
az account show
@@ -81,22 +61,14 @@ jobs:
- name: Login with individual parameters
id: login_2
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 }}
# subscription-id: ${{ secrets.OIDC_SP2_SUBSCRIPTION_ID }}
allow-no-subscriptions: true
enable-AzPSSession: true
- name: Check Last step failed
if: steps.login_2.outcome == 'success'
uses: actions/github-script@v3
with:
script: |
core.setFailed('Last action should fail but not. Please check it.')
- name: Run Azure Cli again
run: |
az account show
@@ -144,7 +116,7 @@ jobs:
with:
client-id: ${{ secrets.OIDC_SP2_CLIENT_ID }}
tenant-id: ${{ secrets.OIDC_SP2_TENANT_ID }}
subscription-id: ${{ secrets.OIDC_SP2_SUBSCRIPTION_ID }}
# subscription-id: ${{ secrets.OIDC_SP2_SUBSCRIPTION_ID }}
allow-no-subscriptions: true
enable-AzPSSession: true
@@ -357,3 +329,89 @@ jobs:
with:
script: |
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.')
- name: Login by OIDC with all info in creds
id: login_13
continue-on-error: true
uses: ./
with:
creds: ${{secrets.SP2}}
allow-no-subscriptions: true
enable-AzPSSession: true
- 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.')
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_14
continue-on-error: true
uses: ./
- name: Check Last step failed
if: steps.login_14.outcome == 'success'
uses: actions/github-script@v3
with:
script: |
core.setFailed('Last action should fail but not. Please check it.')

View File

@@ -60,7 +60,7 @@ jobs:
with:
client-id: ${{ secrets.OIDC_SP2_CLIENT_ID }}
tenant-id: ${{ secrets.OIDC_SP2_TENANT_ID }}
subscription-id: ${{ secrets.OIDC_SP2_SUBSCRIPTION_ID }}
# subscription-id: ${{ secrets.OIDC_SP2_SUBSCRIPTION_ID }}
allow-no-subscriptions: true
enable-AzPSSession: true
@@ -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 GitHubAction_CI_RG
az vm list
- name: Run Azure PowerShell
uses: azure/powershell@v1.2.0
with:
azPSVersion: "latest"
inlineScript: |
Get-AzContext | Format-List
Get-AzResourceGroup -Name GitHubAction_CI_RG
Get-AzVM
ParameterTest:
strategy:
matrix:
@@ -96,30 +118,6 @@ jobs:
npm install
npm run build
- name: Login with both creds and individual parameters
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
- name: Run Azure Cli
run: |
az account show
az group show --name GitHubAction_CI_RG
az vm list
- name: Run Azure PowerShell
uses: azure/powershell@v1.2.0
with:
azPSVersion: "latest"
inlineScript: |
Get-AzContext | Format-List
Get-AzResourceGroup -Name GitHubAction_CI_RG
Get-AzVM
- name: Login with creds, disable ps session
uses: ./
with:
@@ -185,3 +183,107 @@ 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, no subscription-id
uses: ./
with:
auth-type: IDENTITY
allow-no-subscriptions: true
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 system-assigned managed identity, with subscription id
uses: ./
with:
auth-type: IDENTITY
subscription-id: ${{ secrets.AZURE_SUBSCRIPTIONID }}
enable-AzPSSession: true
- name: Run Azure Cli
run: |
az account show
az group show --name GitHubAction_CI_RG
az vm list
- name: Run Azure PowerShell
uses: azure/powershell@v1.2.0
with:
azPSVersion: "latest"
inlineScript: |
Get-AzContext | Format-List
Get-AzResourceGroup -Name GitHubAction_CI_RG
Get-AzVM
- 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
- 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
az group show --name GitHubAction_CI_RG
az vm list
- name: Run Azure PowerShell
uses: azure/powershell@v1.2.0
with:
azPSVersion: "latest"
inlineScript: |
Get-AzContext | Format-List
Get-AzResourceGroup -Name GitHubAction_CI_RG
Get-AzVM

View File

@@ -0,0 +1,259 @@
import { LoginConfig } from "../src/common/LoginConfig";
describe("LoginConfig Test", () => {
function setEnv(name: string, value: string) {
process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] = value;
}
function cleanEnv() {
for (const envKey in process.env) {
if (envKey.startsWith('INPUT_')) {
delete process.env[envKey]
}
}
}
async function testCreds(creds:any){
setEnv('environment', 'azurecloud');
setEnv('enable-AzPSSession', 'true');
setEnv('allow-no-subscriptions', 'false');
setEnv('auth-type', 'SERVICE_PRINCIPAL');
setEnv('creds', JSON.stringify(creds));
let loginConfig = new LoginConfig();
try{
await loginConfig.initialize();
throw new Error("The last step should fail.");
}catch(error){
expect(error.message.includes("Not all parameters are provided in 'creds'.")).toBeTruthy();
}
}
function testValidateWithErrorMessage(loginConfig:LoginConfig, errorMessage:string){
try{
loginConfig.validate();
throw new Error("The last step should fail.");
}catch(error){
expect(error.message.includes(errorMessage)).toBeTruthy();
}
}
beforeEach(() => {
cleanEnv();
});
test('initialize with creds, lack of clientId', async () => {
let creds1 = {
// 'clientId': 'client-id',
'clientSecret': 'client-secret',
'tenantId': 'tenant-id',
'subscriptionId': 'subscription-id'
}
await testCreds(creds1);
});
test('initialize with creds, lack of clientSecret', async () => {
let creds1 = {
'clientId': 'client-id',
// 'clientSecret': 'client-secret',
'tenantId': 'tenant-id',
'subscriptionId': 'subscription-id'
}
await testCreds(creds1);
});
test('initialize with creds, lack of tenantId', async () => {
let creds1 = {
'clientId': 'client-id',
'clientSecret': 'client-secret',
// 'tenantId': 'tenant-id',
'subscriptionId': 'subscription-id'
}
await testCreds(creds1);
});
test('initialize with creds, lack of subscriptionId', async () => {
let creds1 = {
'clientId': 'client-id',
'clientSecret': 'client-secret',
'tenantId': 'tenant-id',
// 'subscriptionId': 'subscription-id'
}
await testCreds(creds1);
});
test('initialize with creds', async () => {
let creds = {
'clientId': 'client-id',
'clientSecret': 'client-secret',
'tenantId': 'tenant-id',
'subscriptionId': 'subscription-id'
}
setEnv('environment', 'azurecloud');
setEnv('enable-AzPSSession', 'true');
setEnv('allow-no-subscriptions', 'false');
setEnv('auth-type', 'SERVICE_PRINCIPAL');
setEnv('creds', JSON.stringify(creds));
let loginConfig = new LoginConfig();
await loginConfig.initialize();
expect(loginConfig.environment).toBe("azurecloud");
expect(loginConfig.enableAzPSSession).toBeTruthy();
expect(loginConfig.allowNoSubscriptionsLogin).toBeFalsy();
expect(loginConfig.authType).toBe("SERVICE_PRINCIPAL");
expect(loginConfig.servicePrincipalId).toBe("client-id");
expect(loginConfig.servicePrincipalSecret).toBe("client-secret");
expect(loginConfig.tenantId).toBe("tenant-id");
expect(loginConfig.subscriptionId).toBe("subscription-id");
});
test('initialize with individual parameters', async () => {
setEnv('environment', 'azureusgovernment');
setEnv('enable-AzPSSession', 'false');
setEnv('allow-no-subscriptions', 'true');
setEnv('auth-type', 'SERVICE_PRINCIPAL');
setEnv('tenant-id', 'tenant-id');
setEnv('subscription-id', 'subscription-id');
setEnv('client-id', 'client-id');
let loginConfig = new LoginConfig();
await loginConfig.initialize();
expect(loginConfig.environment).toBe("azureusgovernment");
expect(loginConfig.enableAzPSSession).toBeFalsy();
expect(loginConfig.allowNoSubscriptionsLogin).toBeTruthy();
expect(loginConfig.authType).toBe("SERVICE_PRINCIPAL");
expect(loginConfig.servicePrincipalId).toBe("client-id");
expect(loginConfig.tenantId).toBe("tenant-id");
expect(loginConfig.subscriptionId).toBe("subscription-id");
});
test('initialize with both creds and individual parameters', async () => {
setEnv('environment', 'azureusgovernment');
setEnv('enable-AzPSSession', 'false');
setEnv('allow-no-subscriptions', 'true');
setEnv('auth-type', 'SERVICE_PRINCIPAL');
setEnv('tenant-id', 'tenant-id-aa');
setEnv('subscription-id', 'subscription-id-aa');
setEnv('client-id', 'client-id-aa');
let creds = {
'clientId': 'client-id-bb',
'clientSecret': 'client-secret-bb',
'tenantId': 'tenant-id-bb',
'subscriptionId': 'subscription-id-bb'
}
setEnv('creds', JSON.stringify(creds));
let loginConfig = new LoginConfig();
await loginConfig.initialize();
expect(loginConfig.environment).toBe("azureusgovernment");
expect(loginConfig.enableAzPSSession).toBeFalsy();
expect(loginConfig.allowNoSubscriptionsLogin).toBeTruthy();
expect(loginConfig.authType).toBe("SERVICE_PRINCIPAL");
expect(loginConfig.servicePrincipalId).toBe("client-id-aa");
expect(loginConfig.servicePrincipalSecret).toBeNull();
expect(loginConfig.tenantId).toBe("tenant-id-aa");
expect(loginConfig.subscriptionId).toBe("subscription-id-aa");
});
test('validate with wrong environment', async () => {
setEnv('environment', 'aWrongCloud');
setEnv('enable-AzPSSession', 'false');
setEnv('allow-no-subscriptions', 'true');
setEnv('auth-type', 'SERVICE_PRINCIPAL');
setEnv('tenant-id', 'tenant-id');
setEnv('subscription-id', 'subscription-id');
setEnv('client-id', 'client-id');
let loginConfig = new LoginConfig();
await loginConfig.initialize();
testValidateWithErrorMessage(loginConfig, "Unsupported value 'awrongcloud' for environment is passed.");
});
test('validate with wrong authType', async () => {
setEnv('environment', 'azurestack');
setEnv('enable-AzPSSession', 'false');
setEnv('allow-no-subscriptions', 'true');
setEnv('auth-type', 'SERVICE-PRINCIPAL');
setEnv('tenant-id', 'tenant-id');
setEnv('subscription-id', 'subscription-id');
setEnv('client-id', 'client-id');
let loginConfig = new LoginConfig();
await loginConfig.initialize();
testValidateWithErrorMessage(loginConfig, "Unsupported value 'SERVICE-PRINCIPAL' for authentication type is passed.");
});
test('validate with SERVICE_PRINCIPAL, lack of tenant id', async () => {
setEnv('environment', 'azurestack');
setEnv('enable-AzPSSession', 'false');
setEnv('allow-no-subscriptions', 'true');
setEnv('auth-type', 'SERVICE_PRINCIPAL');
// setEnv('tenant-id', 'tenant-id');
setEnv('subscription-id', 'subscription-id');
setEnv('client-id', 'client-id');
let loginConfig = new LoginConfig();
await loginConfig.initialize();
testValidateWithErrorMessage(loginConfig, "Using auth-type: SERVICE_PRINCIPAL. Not all values are present. Ensure 'client-id' and 'tenant-id' are supplied.");
});
test('validate with SERVICE_PRINCIPAL, lack of client id', async () => {
setEnv('environment', 'azurestack');
setEnv('enable-AzPSSession', 'false');
setEnv('allow-no-subscriptions', 'true');
setEnv('auth-type', 'SERVICE_PRINCIPAL');
setEnv('tenant-id', 'tenant-id');
setEnv('subscription-id', 'subscription-id');
// setEnv('client-id', 'client-id');
let loginConfig = new LoginConfig();
await loginConfig.initialize();
testValidateWithErrorMessage(loginConfig, "Using auth-type: SERVICE_PRINCIPAL. Not all values are present. Ensure 'client-id' and 'tenant-id' are supplied.");
});
test('validate without subscriptionId and allowNoSubscriptionsLogin=false', async () => {
setEnv('environment', 'azurestack');
setEnv('enable-AzPSSession', 'false');
setEnv('allow-no-subscriptions', 'false');
setEnv('auth-type', 'IDENTITY');
// setEnv('subscription-id', 'subscription-id');
let loginConfig = new LoginConfig();
await loginConfig.initialize();
testValidateWithErrorMessage(loginConfig, "Ensure subscriptionId is supplied.");
});
test('validate without subscriptionId and allowNoSubscriptionsLogin=true', async () => {
setEnv('environment', 'azurestack');
setEnv('enable-AzPSSession', 'true');
setEnv('allow-no-subscriptions', 'true');
setEnv('auth-type', 'IDENTITY');
// setEnv('subscription-id', 'subscription-id');
let loginConfig = new LoginConfig();
await loginConfig.initialize();
loginConfig.validate();
expect(loginConfig.environment).toBe("azurestack");
expect(loginConfig.enableAzPSSession).toBeTruthy();
expect(loginConfig.allowNoSubscriptionsLogin).toBeTruthy();
expect(loginConfig.authType).toBe("IDENTITY");
expect(loginConfig.servicePrincipalId).toBe("");
expect(loginConfig.servicePrincipalSecret).toBeNull();
expect(loginConfig.tenantId).toBe("");
expect(loginConfig.subscriptionId).toBe("");
});
});

View File

@@ -0,0 +1,92 @@
import * as os from 'os';
import { AzPSLogin } from '../../src/PowerShell/AzPSLogin';
import { LoginConfig } from '../../src/common/LoginConfig';
import AzPSConstants from '../../src/PowerShell/AzPSConstants';
let azpsLogin: AzPSLogin;
jest.setTimeout(30000);
beforeAll(() => {
var loginConfig = new LoginConfig();
loginConfig.servicePrincipalId = "servicePrincipalID";
loginConfig.servicePrincipalSecret = "servicePrincipalSecret";
loginConfig.tenantId = "tenantId";
loginConfig.subscriptionId = "subscriptionId";
azpsLogin = new AzPSLogin(loginConfig);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Testing login', () => {
let loginSpy;
beforeEach(() => {
loginSpy = jest.spyOn(azpsLogin, 'login');
});
test('ServicePrincipal login should pass', async () => {
loginSpy.mockImplementationOnce(() => Promise.resolve());
await azpsLogin.login();
expect(loginSpy).toHaveBeenCalled();
});
});
describe('Testing set module path', () => {
test('setDefaultPSModulePath should work', () => {
azpsLogin.setPSModulePathForGitHubRunner();
const runner: string = process.env.RUNNER_OS || os.type();
if(runner.toLowerCase() === "linux"){
expect(process.env.PSModulePath).toContain(AzPSConstants.DEFAULT_AZ_PATH_ON_LINUX);
}
if(runner.toLowerCase().startsWith("windows")){
expect(process.env.PSModulePath).toContain(AzPSConstants.DEFAULT_AZ_PATH_ON_WINDOWS);
}
});
});
describe('Testing runPSScript', () => {
test('Get PowerShell Version', async () => {
let script = `try {
$ErrorActionPreference = "Stop"
$WarningPreference = "SilentlyContinue"
$output = @{}
$output['Success'] = $true
$output['Result'] = $PSVersionTable.PSVersion.ToString()
}
catch {
$output['Success'] = $false
$output['Error'] = $_.exception.Message
}
return ConvertTo-Json $output`;
let psVersion: string = await AzPSLogin.runPSScript(script);
expect(psVersion === null).toBeFalsy();
});
test('Get PowerShell Version with Wrong Name', async () => {
let script = `try {
$ErrorActionPreference = "Stop"
$WarningPreference = "SilentlyContinue"
$output = @{}
$output['Success'] = $true
$output['Result'] = $PSVersionTableWrongName.PSVersion.ToString()
}
catch {
$output['Success'] = $false
$output['Error'] = $_.exception.Message
}
return ConvertTo-Json $output`;
try{
await AzPSLogin.runPSScript(script);
throw new Error("The last step should fail.");
}catch(error){
expect(error.message.includes("Azure PowerShell login failed with error: You cannot call a method on a null-valued expression.")).toBeTruthy();
}
});
});

View File

@@ -0,0 +1,153 @@
import AzPSSCriptBuilder from "../../src/PowerShell/AzPSScriptBuilder";
import { LoginConfig } from "../../src/common/LoginConfig";
describe("Getting AzLogin PS script", () => {
function setEnv(name: string, value: string) {
process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] = value;
}
function cleanEnv() {
for (const envKey in process.env) {
if (envKey.startsWith('INPUT_')) {
delete process.env[envKey]
}
}
}
beforeEach(() => {
cleanEnv();
});
test('getImportLatestModuleScript', () => {
expect(AzPSSCriptBuilder.getImportLatestModuleScript("TestModule")).toContain("(Get-Module -Name 'TestModule' -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1).Path");
expect(AzPSSCriptBuilder.getImportLatestModuleScript("TestModule")).toContain("Import-Module -Name $latestModulePath");
});
test('getAzPSLoginScript for SP+secret with allowNoSubscriptionsLogin=true', () => {
setEnv('environment', 'azurecloud');
setEnv('enable-AzPSSession', 'true');
setEnv('allow-no-subscriptions', 'true');
setEnv('auth-type', 'SERVICE_PRINCIPAL');
let creds = {
'clientId': 'client-id',
'clientSecret': "client-secret",
'tenantId': 'tenant-id',
'subscriptionId': 'subscription-id'
}
setEnv('creds', JSON.stringify(creds));
let loginConfig = new LoginConfig();
loginConfig.initialize();
return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => {
expect(loginScript.includes("Clear-AzContext -Scope Process; Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue; $psLoginSecrets = ConvertTo-SecureString 'client-secret' -AsPlainText -Force; $psLoginCredential = New-Object System.Management.Automation.PSCredential('client-id', $psLoginSecrets); Connect-AzAccount -ServicePrincipal -Environment 'azurecloud' -Tenant 'tenant-id' -Subscription 'subscription-id' -Credential $psLoginCredential | out-null;")).toBeTruthy();
expect(loginMethod).toBe('service principal with secret');
});
});
test('getAzPSLoginScript for SP+secret with allowNoSubscriptionsLogin=true, secret with single-quote', () => {
setEnv('environment', 'azurecloud');
setEnv('enable-AzPSSession', 'true');
setEnv('allow-no-subscriptions', 'true');
setEnv('auth-type', 'SERVICE_PRINCIPAL');
let creds = {
'clientId': 'client-id',
'clientSecret': "client-se'cret",
'tenantId': 'tenant-id',
'subscriptionId': 'subscription-id'
}
setEnv('creds', JSON.stringify(creds));
let loginConfig = new LoginConfig();
loginConfig.initialize();
return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => {
expect(loginScript.includes("Clear-AzContext -Scope Process; Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue; $psLoginSecrets = ConvertTo-SecureString 'client-se''cret' -AsPlainText -Force; $psLoginCredential = New-Object System.Management.Automation.PSCredential('client-id', $psLoginSecrets); Connect-AzAccount -ServicePrincipal -Environment 'azurecloud' -Tenant 'tenant-id' -Subscription 'subscription-id' -Credential $psLoginCredential | out-null;")).toBeTruthy();
expect(loginMethod).toBe('service principal with secret');
});
});
test('getAzPSLoginScript for SP+secret with allowNoSubscriptionsLogin=false', () => {
setEnv('environment', 'azurecloud');
setEnv('enable-AzPSSession', 'true');
setEnv('allow-no-subscriptions', 'false'); // same as true
setEnv('auth-type', 'SERVICE_PRINCIPAL');
let creds = {
'clientId': 'client-id',
'clientSecret': 'client-secret',
'tenantId': 'tenant-id',
'subscriptionId': 'subscription-id'
}
setEnv('creds', JSON.stringify(creds));
let loginConfig = new LoginConfig();
loginConfig.initialize();
return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => {
expect(loginScript.includes("Clear-AzContext -Scope Process; Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue; $psLoginSecrets = ConvertTo-SecureString 'client-secret' -AsPlainText -Force; $psLoginCredential = New-Object System.Management.Automation.PSCredential('client-id', $psLoginSecrets); Connect-AzAccount -ServicePrincipal -Environment 'azurecloud' -Tenant 'tenant-id' -Subscription 'subscription-id' -Credential $psLoginCredential | out-null;")).toBeTruthy();
expect(loginMethod).toBe('service principal with secret');
});
});
test('getAzPSLoginScript for OIDC', () => {
setEnv('environment', 'azurecloud');
setEnv('enable-AzPSSession', 'true');
setEnv('allow-no-subscriptions', 'false');
setEnv('tenant-id', 'tenant-id');
setEnv('subscription-id', 'subscription-id');
setEnv('client-id', 'client-id');
setEnv('auth-type', 'SERVICE_PRINCIPAL');
let loginConfig = new LoginConfig();
loginConfig.initialize();
jest.spyOn(loginConfig, 'getFederatedToken').mockImplementation(async () => {loginConfig.federatedToken = "fake-token";});
return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => {
expect(loginScript.includes("Clear-AzContext -Scope Process; Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue; Connect-AzAccount -ServicePrincipal -Environment 'azurecloud' -Tenant 'tenant-id' -Subscription 'subscription-id' -ApplicationId 'client-id' -FederatedToken 'fake-token' | out-null;")).toBeTruthy();
expect(loginMethod).toBe('OIDC');
});
});
test('getAzPSLoginScript for System MI', () => {
setEnv('environment', 'azurecloud');
setEnv('enable-AzPSSession', 'true');
setEnv('allow-no-subscriptions', 'false');
setEnv('subscription-id', 'subscription-id');
setEnv('auth-type', 'IDENTITY');
let loginConfig = new LoginConfig();
loginConfig.initialize();
return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => {
expect(loginScript.includes("Clear-AzContext -Scope Process; Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue; Connect-AzAccount -Identity -Environment 'azurecloud' -Subscription 'subscription-id' | out-null;")).toBeTruthy();
expect(loginMethod).toBe('system-assigned managed identity');
});
});
test('getAzPSLoginScript for System MI without subscription id', () => {
setEnv('environment', 'azurecloud');
setEnv('enable-AzPSSession', 'true');
setEnv('allow-no-subscriptions', 'false');
// setEnv('subscription-id', 'subscription-id');
setEnv('auth-type', 'IDENTITY');
let loginConfig = new LoginConfig();
loginConfig.initialize();
return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => {
expect(loginScript.includes("Clear-AzContext -Scope Process; Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue; Connect-AzAccount -Identity -Environment 'azurecloud' | out-null;")).toBeTruthy();
expect(loginMethod).toBe('system-assigned managed identity');
});
});
test('getAzPSLoginScript for user-assigned MI', () => {
setEnv('environment', 'azurecloud');
setEnv('enable-AzPSSession', 'true');
setEnv('allow-no-subscriptions', 'true');
setEnv('auth-type', 'IDENTITY');
setEnv('client-id', 'client-id');
let loginConfig = new LoginConfig();
loginConfig.initialize();
return AzPSSCriptBuilder.getAzPSLoginScript(loginConfig).then(([loginMethod, loginScript]) => {
expect(loginScript.includes("Clear-AzContext -Scope Process; Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue; Connect-AzAccount -Identity -Environment 'azurecloud' -AccountId 'client-id' | out-null;")).toBeTruthy();
expect(loginMethod).toBe('user-assigned managed identity');
});
});
});

View File

@@ -1,44 +0,0 @@
import { ServicePrincipalLogin } from '../../src/PowerShell/ServicePrincipalLogin';
import { LoginConfig } from '../../src/common/LoginConfig';
jest.mock('../../src/PowerShell/Utilities/Utils');
jest.mock('../../src/PowerShell/Utilities/PowerShellToolRunner');
let spnlogin: ServicePrincipalLogin;
beforeAll(() => {
var loginConfig = new LoginConfig();
loginConfig.servicePrincipalId = "servicePrincipalID";
loginConfig.servicePrincipalKey = "servicePrinicipalkey";
loginConfig.tenantId = "tenantId";
loginConfig.subscriptionId = "subscriptionId";
spnlogin = new ServicePrincipalLogin(loginConfig);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Testing initialize', () => {
let initializeSpy;
beforeEach(() => {
initializeSpy = jest.spyOn(spnlogin, 'initialize');
});
test('ServicePrincipalLogin initialize should pass', async () => {
await spnlogin.initialize();
expect(initializeSpy).toHaveBeenCalled();
});
});
describe('Testing login', () => {
let loginSpy;
beforeEach(() => {
loginSpy = jest.spyOn(spnlogin, 'login');
});
test('ServicePrincipal login should pass', async () => {
loginSpy.mockImplementationOnce(() => Promise.resolve());
await spnlogin.login();
expect(loginSpy).toHaveBeenCalled();
});
});

View File

@@ -1,25 +0,0 @@
import ScriptBuilder from "../../../src/PowerShell/Utilities/ScriptBuilder";
import Constants from "../../../src/PowerShell/Constants";
describe("Getting AzLogin PS script" , () => {
const scheme = Constants.ServicePrincipal;
let args: any = {
servicePrincipalId: "service-principal-id",
servicePrincipalKey: "service-principal-key",
environment: "environment",
scopeLevel: Constants.Subscription,
subscriptionId: "subId",
allowNoSubscriptionsLogin: true
}
test("PS script should not set context while passing allowNoSubscriptionsLogin as true", () => {
const loginScript = new ScriptBuilder().getAzPSLoginScript(scheme, "tenant-id", args);
expect(loginScript.includes("Set-AzContext -SubscriptionId")).toBeFalsy();
});
test("PS script should set context while passing allowNoSubscriptionsLogin as false", () => {
args["allowNoSubscriptionsLogin"] = false;
const loginScript = new ScriptBuilder().getAzPSLoginScript(scheme, "tenant-id", args);
expect(loginScript.includes("Set-AzContext -SubscriptionId")).toBeTruthy();
});
});

View File

@@ -1,45 +0,0 @@
import Utils from '../../../src/PowerShell/Utilities/Utils';
const version: string = '9.0.0';
const moduleName: string = 'az';
afterEach(() => {
jest.restoreAllMocks();
});
describe('Testing isValidVersion', () => {
const validVersion: string = '1.2.4';
const invalidVersion: string = 'a.bcd';
test('isValidVersion should be true', () => {
expect(Utils.isValidVersion(validVersion)).toBeTruthy();
});
test('isValidVersion should be false', () => {
expect(Utils.isValidVersion(invalidVersion)).toBeFalsy();
});
});
describe('Testing setPSModulePath', () => {
test('PSModulePath with azPSVersion non-empty', () => {
Utils.setPSModulePath(version);
expect(process.env.PSModulePath).toContain(version);
});
test('PSModulePath with azPSVersion empty', () => {
const prevPSModulePath = process.env.PSModulePath;
Utils.setPSModulePath();
expect(process.env.PSModulePath).not.toEqual(prevPSModulePath);
});
});
describe('Testing getLatestModule', () => {
let getLatestModuleSpy;
beforeEach(() => {
getLatestModuleSpy = jest.spyOn(Utils, 'getLatestModule');
});
test('getLatestModule should pass', async () => {
getLatestModuleSpy.mockImplementationOnce((_moduleName: string) => Promise.resolve(version));
await Utils.getLatestModule(moduleName);
expect(getLatestModuleSpy).toHaveBeenCalled();
});
});

View File

@@ -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 and run your Azure CLI or Azure PowerShell based actions or scripts.'
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'

View File

@@ -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() {
core.info(`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);
console.log(`Done setting cloud: "${this.loginConfig.environment}"`);
await this.executeAzCliCommand(["cloud", "set", "-n", this.loginConfig.environment], false);
core.info(`Done setting cloud: "${this.loginConfig.environment}"`);
// Attempting Az cli login
var commonArgs = ["--service-principal",
"-u", this.loginConfig.servicePrincipalId,
if (this.loginConfig.authType === LoginConfig.AUTH_TYPE_SERVICE_PRINCIPAL) {
let args = ["--service-principal",
"--username", 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.servicePrincipalSecret) {
await this.loginWithSecret(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}`);
await this.loginWithOIDC(args);
}
}
else {
let args = ["--identity"];
if (this.loginConfig.servicePrincipalId) {
await this.loginWithUserAssignedIdentity(args);
}
else {
await this.loginWithSystemAssignedIdentity(args);
}
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);
}
}
@@ -68,16 +70,16 @@ export class AzureCliLogin {
throw new Error("resourceManagerEndpointUrl is a required parameter when environment is defined.");
}
console.log(`Unregistering cloud: "${this.loginConfig.environment}" first if it exists`);
core.info(`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}"`);
core.info(`Ignore cloud not registered error: "${error}"`);
}
console.log(`Registering cloud: "${this.loginConfig.environment}" with ARM endpoint: "${this.loginConfig.resourceManagerEndpointUrl}"`);
core.info(`Registering cloud: "${this.loginConfig.environment}" with ARM endpoint: "${this.loginConfig.resourceManagerEndpointUrl}"`);
try {
let baseUri = this.loginConfig.resourceManagerEndpointUrl;
if (baseUri.endsWith('/')) {
@@ -86,22 +88,63 @@ 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}"`)
core.info(`Done registering cloud: "${this.loginConfig.environment}"`)
}
async loginWithSecret(args: string[]) {
core.info("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.servicePrincipalSecret}`);
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) {
core.info(`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();
core.info(`Azure CLI login succeeds by using ${methodName}.`);
}
async setSubscription() {
if (this.loginConfig.allowNoSubscriptionsLogin) {
return;
}
let args = ["account", "set", "--subscription", this.loginConfig.subscriptionId];
await this.executeAzCliCommand(args, true, this.loginOptions);
core.info("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,7 +162,7 @@ function defaultExecOptions(): exec.ExecOptions {
//removing the keyword 'ERROR' to avoid duplicates while throwing error
error = error.slice(5);
}
core.setFailed(error);
core.error(error);
}
}
}

View File

@@ -0,0 +1,7 @@
export default class AzPSConstants {
static readonly DEFAULT_AZ_PATH_ON_LINUX: string = '/usr/share';
static readonly DEFAULT_AZ_PATH_ON_WINDOWS: string = 'C:\\Modules';
static readonly AzAccounts: string = "Az.Accounts";
static readonly PowerShell_CmdName = "pwsh";
}

100
src/PowerShell/AzPSLogin.ts Normal file
View File

@@ -0,0 +1,100 @@
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import * as io from '@actions/io';
import * as os from 'os';
import * as path from 'path';
import AzPSScriptBuilder from './AzPSScriptBuilder';
import AzPSConstants from './AzPSConstants';
import { LoginConfig } from '../common/LoginConfig';
interface PSResultType {
Result: string;
Success: boolean;
Error: string;
}
export class AzPSLogin {
loginConfig: LoginConfig;
constructor(loginConfig: LoginConfig) {
this.loginConfig = loginConfig;
}
async login() {
core.info(`Running Azure PowerShell Login.`);
this.setPSModulePathForGitHubRunner();
await this.importLatestAzAccounts();
const [loginMethod, loginScript] = await AzPSScriptBuilder.getAzPSLoginScript(this.loginConfig);
core.info(`Attempting Azure PowerShell login by using ${loginMethod}...`);
core.debug(`Azure PowerShell Login Script: ${loginScript}`);
await AzPSLogin.runPSScript(loginScript);
console.log(`Running Azure PowerShell Login successfully.`);
}
setPSModulePathForGitHubRunner() {
const runner: string = process.env.RUNNER_OS || os.type();
switch (runner.toLowerCase()) {
case "linux":
this.pushPSModulePath(AzPSConstants.DEFAULT_AZ_PATH_ON_LINUX);
break;
case "windows":
case "windows_nt":
this.pushPSModulePath(AzPSConstants.DEFAULT_AZ_PATH_ON_WINDOWS);
break;
case "macos":
case "darwin":
core.warning(`Skip setting the default PowerShell module path for OS ${runner.toLowerCase()}.`);
break;
default:
core.warning(`Skip setting the default PowerShell module path for unknown OS ${runner.toLowerCase()}.`);
break;
}
}
private pushPSModulePath(psModulePath: string) {
process.env.PSModulePath = `${psModulePath}${path.delimiter}${process.env.PSModulePath}`;
core.debug(`Set PSModulePath as ${process.env.PSModulePath}`);
}
private async importLatestAzAccounts() {
let importLatestAccountsScript: string = AzPSScriptBuilder.getImportLatestModuleScript(AzPSConstants.AzAccounts);
core.debug(`The script to import the latest Az.Accounts: ${importLatestAccountsScript}`);
let azAccountsPath: string = await AzPSLogin.runPSScript(importLatestAccountsScript);
core.debug(`The latest Az.Accounts used: ${azAccountsPath}`);
}
static async runPSScript(psScript: string): Promise<string> {
let outputString: string = "";
let commandStdErr = false;
const options: any = {
silent: true,
listeners: {
stdout: (data: Buffer) => {
outputString += data.toString();
},
stderr: (data: Buffer) => {
let error = data.toString();
if (error && error.trim().length !== 0) {
commandStdErr = true;
core.error(error);
}
}
}
};
let psPath: string = await io.which(AzPSConstants.PowerShell_CmdName, true);
await exec.exec(`"${psPath}"`, ["-Command", psScript], options)
if (commandStdErr) {
throw new Error('Azure PowerShell login failed with errors.');
}
const result: PSResultType = JSON.parse(outputString.trim());
console.log(result);
if (!(result.Success)) {
throw new Error(`Azure PowerShell login failed with error: ${result.Error}`);
}
return result.Result;
}
}

View File

@@ -0,0 +1,113 @@
import AzPSConstants from "./AzPSConstants";
import { LoginConfig } from '../common/LoginConfig';
export default class AzPSScriptBuilder {
static getImportLatestModuleScript(moduleName: string): string {
let script = `try {
$ErrorActionPreference = "Stop"
$WarningPreference = "SilentlyContinue"
$output = @{}
$latestModulePath = (Get-Module -Name '${moduleName}' -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1).Path
Import-Module -Name $latestModulePath
$output['Success'] = $true
$output['Result'] = $latestModulePath
}
catch {
$output['Success'] = $false
$output['Error'] = $_.exception.Message
}
return ConvertTo-Json $output`;
return script;
}
static async getAzPSLoginScript(loginConfig: LoginConfig) {
let loginMethodName = "";
let commands = 'Clear-AzContext -Scope Process; ';
commands += 'Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue; ';
if (loginConfig.environment.toLowerCase() == "azurestack") {
commands += `Add-AzEnvironment -Name '${loginConfig.environment}' -ARMEndpoint '${loginConfig.resourceManagerEndpointUrl}' | out-null;`;
}
if (loginConfig.authType === LoginConfig.AUTH_TYPE_SERVICE_PRINCIPAL) {
if (loginConfig.servicePrincipalSecret) {
commands += AzPSScriptBuilder.loginWithSecret(loginConfig);
loginMethodName = 'service principal with secret';
} else {
commands += await AzPSScriptBuilder.loginWithOIDC(loginConfig);
loginMethodName = "OIDC";
}
} else {
if (loginConfig.servicePrincipalId) {
commands += AzPSScriptBuilder.loginWithUserAssignedIdentity(loginConfig);
loginMethodName = 'user-assigned managed identity';
} else {
commands += AzPSScriptBuilder.loginWithSystemAssignedIdentity(loginConfig);
loginMethodName = 'system-assigned managed identity';
}
}
let script = `try {
$ErrorActionPreference = "Stop"
$WarningPreference = "SilentlyContinue"
$output = @{}
${commands}
$output['Success'] = $true
$output['Result'] = ""
}
catch {
$output['Success'] = $false
$output['Error'] = $_.exception.Message
}
return ConvertTo-Json $output`;
return [loginMethodName, script];
}
private static loginWithSecret(loginConfig: LoginConfig): string {
let servicePrincipalSecret: string = loginConfig.servicePrincipalSecret.split("'").join("''");
let loginCmdlet = `$psLoginSecrets = ConvertTo-SecureString '${servicePrincipalSecret}' -AsPlainText -Force; `;
loginCmdlet += `$psLoginCredential = New-Object System.Management.Automation.PSCredential('${loginConfig.servicePrincipalId}', $psLoginSecrets); `;
let cmdletSuffix = "-Credential $psLoginCredential";
loginCmdlet += AzPSScriptBuilder.psLoginCmdlet(loginConfig.authType, loginConfig.environment, loginConfig.tenantId, loginConfig.subscriptionId, cmdletSuffix);
return loginCmdlet;
}
private static async loginWithOIDC(loginConfig: LoginConfig) {
await loginConfig.getFederatedToken();
let cmdletSuffix = `-ApplicationId '${loginConfig.servicePrincipalId}' -FederatedToken '${loginConfig.federatedToken}'`;
return AzPSScriptBuilder.psLoginCmdlet(loginConfig.authType, loginConfig.environment, loginConfig.tenantId, loginConfig.subscriptionId, cmdletSuffix);
}
private static loginWithSystemAssignedIdentity(loginConfig: LoginConfig): string {
let cmdletSuffix = "";
return AzPSScriptBuilder.psLoginCmdlet(loginConfig.authType, loginConfig.environment, loginConfig.tenantId, loginConfig.subscriptionId, cmdletSuffix);
}
static loginWithUserAssignedIdentity(loginConfig: LoginConfig): string {
let cmdletSuffix = `-AccountId '${loginConfig.servicePrincipalId}'`;
return AzPSScriptBuilder.psLoginCmdlet(loginConfig.authType, loginConfig.environment, loginConfig.tenantId, loginConfig.subscriptionId, cmdletSuffix);
}
private static psLoginCmdlet(authType:string, environment:string, tenantId:string, subscriptionId:string, cmdletSuffix:string){
let loginCmdlet = `Connect-AzAccount `;
if(authType === LoginConfig.AUTH_TYPE_SERVICE_PRINCIPAL){
loginCmdlet += "-ServicePrincipal ";
}else{
loginCmdlet += "-Identity ";
}
loginCmdlet += `-Environment '${environment}' `;
if(tenantId){
loginCmdlet += `-Tenant '${tenantId}' `;
}
if(subscriptionId){
loginCmdlet += `-Subscription '${subscriptionId}' `;
}
loginCmdlet += `${cmdletSuffix} | out-null;`;
return loginCmdlet;
}
}

View File

@@ -1,13 +0,0 @@
export default class Constants {
static readonly prefix: string = "az_";
static readonly moduleName: string = "Az.Accounts";
static readonly versionPattern = /[0-9]+\.[0-9]+\.[0-9]+/;
static readonly AzureCloud: string = "AzureCloud";
static readonly Subscription: string = "Subscription";
static readonly ServicePrincipal: string = "ServicePrincipal";
static readonly Success: string = "Success";
static readonly Error: string = "Error";
static readonly AzVersion: string = "AzVersion";
}

View File

@@ -1,4 +0,0 @@
interface IAzurePowerShellSession {
initialize();
login();
}

View File

@@ -1,62 +0,0 @@
import * as core from '@actions/core';
import Utils from './Utilities/Utils';
import PowerShellToolRunner from './Utilities/PowerShellToolRunner';
import ScriptBuilder from './Utilities/ScriptBuilder';
import Constants from './Constants';
import { LoginConfig } from '../common/LoginConfig';
export class ServicePrincipalLogin implements IAzurePowerShellSession {
static readonly scopeLevel: string = Constants.Subscription;
static readonly scheme: string = Constants.ServicePrincipal;
loginConfig: LoginConfig;
constructor(loginConfig: LoginConfig) {
this.loginConfig = loginConfig;
}
async initialize() {
Utils.setPSModulePath();
const azLatestVersion: string = await Utils.getLatestModule(Constants.moduleName);
core.debug(`Az Module version used: ${azLatestVersion}`);
Utils.setPSModulePath(`${Constants.prefix}${azLatestVersion}`);
}
async login() {
let output: string = "";
let commandStdErr = false;
const options: any = {
listeners: {
stdout: (data: Buffer) => {
output += data.toString();
},
stderr: (data: Buffer) => {
let error = data.toString();
if (error && error.trim().length !== 0) {
commandStdErr = true;
core.error(error);
}
}
}
};
const args: any = {
servicePrincipalId: this.loginConfig.servicePrincipalId,
servicePrincipalKey: this.loginConfig.servicePrincipalKey,
federatedToken: this.loginConfig.federatedToken,
subscriptionId: this.loginConfig.subscriptionId,
environment: this.loginConfig.environment,
scopeLevel: ServicePrincipalLogin.scopeLevel,
allowNoSubscriptionsLogin: this.loginConfig.allowNoSubscriptionsLogin,
resourceManagerEndpointUrl: this.loginConfig.resourceManagerEndpointUrl
}
const script: string = new ScriptBuilder().getAzPSLoginScript(ServicePrincipalLogin.scheme, this.loginConfig.tenantId, args);
await PowerShellToolRunner.init();
await PowerShellToolRunner.executePowerShellScriptBlock(script, options);
const result: any = JSON.parse(output.trim());
if (!(Constants.Success in result)) {
throw new Error(`Azure PowerShell login failed with error: ${result[Constants.Error]}`);
}
console.log(`Azure PowerShell session successfully initialized`);
}
}

View File

@@ -1,16 +0,0 @@
import * as io from '@actions/io';
import * as exec from '@actions/exec';
export default class PowerShellToolRunner {
static psPath: string;
static async init() {
if(!PowerShellToolRunner.psPath) {
PowerShellToolRunner.psPath = await io.which("pwsh", true);
}
}
static async executePowerShellScriptBlock(scriptBlock: string, options: any = {}) {
//Options for error handling
await exec.exec(`"${PowerShellToolRunner.psPath}" -Command`, [scriptBlock], options)
}
}

View File

@@ -1,67 +0,0 @@
import * as core from '@actions/core';
import Constants from "../Constants";
export default class ScriptBuilder {
script: string = "";
getAzPSLoginScript(scheme: string, tenantId: string, args: any): string {
let command = `Clear-AzContext -Scope Process;
Clear-AzContext -Scope CurrentUser -Force -ErrorAction SilentlyContinue;`;
if (scheme === Constants.ServicePrincipal) {
if (args.environment.toLowerCase() == "azurestack") {
command += `Add-AzEnvironment -Name ${args.environment} -ARMEndpoint ${args.resourceManagerEndpointUrl} | out-null;`;
}
// Separate command script for OIDC and non-OIDC
if (!!args.federatedToken) {
command += `Connect-AzAccount -ServicePrincipal -ApplicationId '${args.servicePrincipalId}' -Tenant '${tenantId}' -FederatedToken '${args.federatedToken}' \
-Environment '${args.environment}' | out-null;`;
}
else {
command += `Connect-AzAccount -ServicePrincipal -Tenant '${tenantId}' -Credential \
(New-Object System.Management.Automation.PSCredential('${args.servicePrincipalId}',(ConvertTo-SecureString '${args.servicePrincipalKey.replace("'", "''")}' -AsPlainText -Force))) \
-Environment '${args.environment}' | out-null;`;
}
// command to set the subscription
if (args.scopeLevel === Constants.Subscription && !args.allowNoSubscriptionsLogin) {
command += `Set-AzContext -SubscriptionId '${args.subscriptionId}' -TenantId '${tenantId}' | out-null;`;
}
}
this.script += `try {
$ErrorActionPreference = "Stop"
$WarningPreference = "SilentlyContinue"
$output = @{}
${command}
$output['${Constants.Success}'] = "true"
}
catch {
$output['${Constants.Error}'] = $_.exception.Message
}
return ConvertTo-Json $output`;
core.debug(`Azure PowerShell Login Script: ${this.script}`);
return this.script;
}
getLatestModuleScript(moduleName: string): string {
const command: string = `Get-Module -Name ${moduleName} -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1`;
this.script += `try {
$ErrorActionPreference = "Stop"
$WarningPreference = "SilentlyContinue"
$output = @{}
$data = ${command}
$output['${Constants.AzVersion}'] = $data.Version.ToString()
$output['${Constants.Success}'] = "true"
}
catch {
$output['${Constants.Error}'] = $_.exception.Message
}
return ConvertTo-Json $output`;
core.debug(`GetLatestModuleScript: ${this.script}`);
return this.script;
}
}

View File

@@ -1,61 +0,0 @@
import * as os from 'os';
import Constants from '../Constants';
import ScriptBuilder from './ScriptBuilder';
import PowerShellToolRunner from './PowerShellToolRunner';
export default class Utils {
/**
* Add the folder path where Az modules are present to PSModulePath based on runner
* @param azPSVersion
* If azPSVersion is empty, folder path in which all Az modules are present are set
* If azPSVersion is not empty, folder path of exact Az module version is set
*/
static setPSModulePath(azPSVersion: string = "") {
let modulePath: string = "";
const runner: string = process.env.RUNNER_OS || os.type();
switch (runner.toLowerCase()) {
case "linux":
modulePath = `/usr/share/${azPSVersion}:`;
break;
case "windows":
case "windows_nt":
modulePath = `C:\\Modules\\${azPSVersion};`;
break;
case "macos":
case "darwin":
throw new Error(`OS not supported`);
default:
throw new Error(`Unknown os: ${runner.toLowerCase()}`);
}
process.env.PSModulePath = `${modulePath}${process.env.PSModulePath}`;
}
static async getLatestModule(moduleName: string): Promise<string> {
let output: string = "";
const options: any = {
listeners: {
stdout: (data: Buffer) => {
output += data.toString();
}
}
};
await PowerShellToolRunner.init();
await PowerShellToolRunner.executePowerShellScriptBlock(new ScriptBuilder()
.getLatestModuleScript(moduleName), options);
const result = JSON.parse(output.trim());
if (!(Constants.Success in result)) {
throw new Error(result[Constants.Error]);
}
const azLatestVersion: string = result[Constants.AzVersion];
if (!Utils.isValidVersion(azLatestVersion)) {
throw new Error(`Invalid AzPSVersion: ${azLatestVersion}`);
}
return azLatestVersion;
}
static isValidVersion(version: string): boolean {
return !!version.match(Constants.versionPattern);
}
}

View File

@@ -2,6 +2,8 @@ import * as core from '@actions/core';
import { FormatType, SecretParser } from 'actions-secret-parser';
export class LoginConfig {
static readonly AUTH_TYPE_SERVICE_PRINCIPAL = "SERVICE_PRINCIPAL";
static readonly AUTH_TYPE_IDENTITY = "IDENTITY";
static readonly azureSupportedCloudName = new Set([
"azureusgovernment",
"azurechinacloud",
@@ -9,78 +11,102 @@ export class LoginConfig {
"azurecloud",
"azurestack"]);
static readonly azureSupportedAuthType = new Set([
LoginConfig.AUTH_TYPE_SERVICE_PRINCIPAL,
LoginConfig.AUTH_TYPE_IDENTITY]);
authType: string;
servicePrincipalId: string;
servicePrincipalKey: string;
servicePrincipalSecret: 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').toUpperCase();
this.servicePrincipalId = core.getInput('client-id', { required: false });
this.servicePrincipalKey = null;
this.servicePrincipalSecret = null;
this.tenantId = core.getInput('tenant-id', { required: false });
this.subscriptionId = core.getInput('subscription-id', { required: false });
this.readParametersFromCreds();
this.audience = core.getInput('audience', { required: false });
this.federatedToken = null;
this.mask(this.servicePrincipalId);
this.mask(this.servicePrincipalSecret);
}
private readParametersFromCreds() {
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);
this.resourceManagerEndpointUrl = secrets.getSecret("$.resourceManagerEndpointUrl", false);
}
this.getFederatedTokenIfNecessary();
}
async getFederatedTokenIfNecessary() {
if (!this.enableOIDC) {
if (!secrets) {
return;
}
if(this.authType != LoginConfig.AUTH_TYPE_SERVICE_PRINCIPAL){
return;
}
if (this.servicePrincipalId || this.tenantId || this.subscriptionId) {
core.warning("At least one of the parameters 'client-id', 'subscription-id' or 'tenant-id' is set. 'creds' will be ignored.");
return;
}
core.debug('Reading creds in JSON...');
this.servicePrincipalId = this.servicePrincipalId ? this.servicePrincipalId : secrets.getSecret("$.clientId", false);
this.servicePrincipalSecret = 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);
if (!this.servicePrincipalId || !this.servicePrincipalSecret || !this.tenantId || !this.subscriptionId) {
throw new Error("Not all parameters are provided in 'creds'. Double-check if all keys are defined in 'creds': 'clientId', 'clientSecret', 'subscriptionId', 'tenantId'.");
}
}
async getFederatedToken() {
try {
this.federatedToken = await core.getIDToken(this.audience);
this.mask(this.federatedToken);
}
catch (error) {
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);
core.info("Federated token details:\n issuer - " + issuer + "\n subject claim - " + subjectClaim);
}
else {
throw new Error("Failed to fetch federated token.");
validate() {
if (!LoginConfig.azureSupportedCloudName.has(this.environment)) {
throw new Error(`Unsupported value '${this.environment}' for environment is passed. The list of supported values for environment are '${Array.from(LoginConfig.azureSupportedCloudName).join("', '")}'. `);
}
if (!LoginConfig.azureSupportedAuthType.has(this.authType)) {
throw new Error(`Unsupported value '${this.authType}' for authentication type is passed. The list of supported values for auth-type are '${Array.from(LoginConfig.azureSupportedAuthType).join("', '")}'.`);
}
if (this.authType === LoginConfig.AUTH_TYPE_SERVICE_PRINCIPAL) {
if (!this.servicePrincipalId || !this.tenantId) {
throw new Error(`Using auth-type: ${LoginConfig.AUTH_TYPE_SERVICE_PRINCIPAL}. Not all values are present. Ensure 'client-id' and 'tenant-id' are supplied.`);
}
}
if (!this.subscriptionId && !this.allowNoSubscriptionsLogin) {
throw new Error("Ensure subscriptionId is supplied.");
}
}
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 (!this.subscriptionId && !this.allowNoSubscriptionsLogin) {
throw new Error("Not all values are present in the credentials. Ensure subscriptionId is 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");
mask(parameterValue: string) {
if (parameterValue) {
core.setSecret(parameterValue);
}
}
}
@@ -91,3 +117,4 @@ async function jwtParser(federatedToken: string) {
let decodedPayload = JSON.parse(bufferObj.toString("utf8"));
return [decodedPayload['iss'], decodedPayload['sub']];
}

View File

@@ -1,5 +1,5 @@
import * as core from '@actions/core';
import { ServicePrincipalLogin } from './PowerShell/ServicePrincipalLogin';
import { AzPSLogin } from './PowerShell/AzPSLogin';
import { LoginConfig } from './common/LoginConfig';
import { AzureCliLogin } from './Cli/AzureCliLogin';
@@ -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,24 @@ 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`);
var spnlogin: ServicePrincipalLogin = new ServicePrincipalLogin(loginConfig);
await spnlogin.initialize();
await spnlogin.login();
var psLogin: AzPSLogin = new AzPSLogin(loginConfig);
await psLogin.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}. Make sure 'az' is installed on the runner. If 'enable-AzPSSession' is true, make sure 'pwsh' is installed on the runner together with Azure PowerShell module. Double check if the 'auth-type' is correct. Refer to https://github.com/Azure/login#readme for more information.`);
core.debug(error.stack);
}
finally {
// Reset AZURE_HTTP_USER_AGENT
@@ -52,3 +42,4 @@ async function main() {
}
main();