Skip to content

Commit

Permalink
Add support for authenticating using an API key (#189)
Browse files Browse the repository at this point in the history
* Add support for authenticating using an API key

This commit adds support for the App Store Connect API key authentication
method, which is the new recommended method by fastlane.

It passes new options through to the --api_key_path option of fastlane.

The options are:
apiKeyId, apiKeyIssuerId, apiKeyContent, apiKeyInHouse

(cherry picked from commit f3c49eb)

* App App Store Connect API Key support to ServiceEndpoint

This commits adds the ability to use the Service Endpoint to provide
API Key credentials. The "token auth" scheme was chosen because it
seems to be the closest match to what the App Store Connect API
Key is.

(cherry picked from commit 9a6c3b6)

* Use base64-encoded private App Store Connect API Key

Azure DevOps doesn't support multi-line string values in input fields which caused problems
with the Private Key for the App Store Connect p8 private key.

Base64-encoding the private key (which is supported by fastlane) solves this.

(cherry picked from commit a98d239)

* Merge branch 'master' into app_store_connect_api_key_support

(cherry picked from commit 1a51081)

* Merge branch 'master' into app_store_connect_api_key_support

(cherry picked from commit 594948c)

* Bump version number to 180.0

(cherry picked from commit e2f5124)

* Merge branch 'master' into app_store_connect_api_key_support

(cherry picked from commit 991b03e)

* Resolved merging conflicts

* Change scheme value from ms.vss-endpoint.endpoint-auth-scheme-token to Token

As noted by @PeterStaev, the value should be "Token".

* Use API Key ID in filename and delete API Key file in a clean up step

To avoid any conflicts, we now use the API Key ID to construct the name of the
API Key JSON file, and we save it inside of `Agent.BuildDirectory` or
`Agent.TempDirectory`.
By default, the API Key JSON file will be deleted after its use. That should
be much safer. Using the `DEBUG_API_KEY_FILE` environment variable keeps it
from being deleted. We use that env var for testing so we can verify that the
file has been created with the correct content.

* fix tests failing on windows

* fix deprecated mocha types

* Change 'apiKeyContent' to 'apitoken' to avoid duplicate field in ADO UI

When using a service endpoint auth type with the new API key, the ADO UI
adds an unused "API Token" field alongside our "API Key Content" field.
As suggested by @PeterStaev, it's better to reuse the "apitoken" field name
with our task-specific labels instead of adding our own field. That reuses
the "apitoken" field that ADO adds anyway, but displays it with our labels.

* fix invalid precheck for in app purchases

* add message about precheck for in app purchases

* Ensure API Key tests clean up api_key.json test files reliably

Since we were cleaning up at the end of the tests, the clean up didn't
happen when an assertion before it failed. That left an api_key.json
file on disk. Now we read the file first and clean up right away.

* apply deliver precheck fix for AppStoreRelease too

* fix service endpoint values for better secret masking

Agent's secret masker assumes that every service endpoint parameter is a secret, so if  'true' and 'false' are used as endpoint values, every 'true' and 'false' string in the logs of the job will be replaced with '***'

* Change `apiKeyFileName` to `apiKeyFilePath` and prefer Agent.TempDirectory

As per @egor-bryzgalov's code review, I've changed the name of the variable
holding the path to the API Key file to make it clearer that it's not just
the file *name*.

Also change where the file is saved on disk: Prefer Agent.TempDirectory
to Agent.BuildDirectory. This required some changes to the tests, since
the system also writes a `.taskkey` file into the temp dir, which we need
to clean up in order to delete the temp directory we had to create for
testing.

Co-authored-by: Egor Bryzgalov <v-egbryz@microsoft.com>
Co-authored-by: DaniilShmelev <daniil.shmelev@akvelon.com>
  • Loading branch information
3 people authored Feb 16, 2021
1 parent f364c3e commit d47166e
Show file tree
Hide file tree
Showing 16 changed files with 1,002 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
"loc.input.label.authType": "Authentication Method",
"loc.input.label.serviceEndpoint": "Service Connection",
"loc.input.help.serviceEndpoint": "The TFS/Azure DevOps service connection that specifies the identity used to publish the app.",
"loc.input.label.apiKeyId": "App Store Connect API Key ID",
"loc.input.help.apiKeyId": "The key_id of the account used to publish to the Apple App Store.",
"loc.input.label.apiKeyIssuerId": "App Store Connect API Issuer ID",
"loc.input.help.apiKeyIssuerId": "The issuer_id of the account used to publish to the Apple App Store.",
"loc.input.label.apitoken": "App Store Connect API Key Content (base64-encoded)",
"loc.input.help.apitoken": "The base64-encoded content of the P8 file of the account used to publish to the Apple App Store.",
"loc.input.label.apiKeyInHouse": "App Store Connect API In House (Enterprise)",
"loc.input.help.apiKeyInHouse": "Whether the account used to publish to the Apple App Store is an Enterprise account or not.",
"loc.input.label.username": "Email",
"loc.input.help.username": "The email of the account used to publish to the Apple App Store.",
"loc.input.label.password": "Password",
Expand Down Expand Up @@ -39,5 +47,6 @@
"loc.messages.DarwinOnly": "The Apple App Store Promote task can only run on a Mac computer.",
"loc.messages.UninstallFastlaneFailed": "There were errors when trying to uninstall fastlane. Review the error and if required add a script to your pipeline to cleanly uninstall fastlane prior to running this task. Uninstall error: %s",
"loc.messages.SuccessfullySubmitted": "Build successfully submitted for review.",
"loc.messages.FastlaneSessionEmpty": "'Fastlane Session' is not set in the service connection configured for two-step verification."
"loc.messages.FastlaneSessionEmpty": "'Fastlane Session' is not set in the service connection configured for two-step verification.",
"loc.messages.PrecheckInAppPurchasesDisabled": "Precheck will not check In-app purchases because Fastlane doesn't support it with the App Store Connect API Key."
}
99 changes: 99 additions & 0 deletions Tasks/AppStorePromote/Tests/L0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// npm install mocha --save-dev
// typings install dt~mocha --save --global

import * as fs from 'fs';
import * as path from 'path';
import * as assert from 'assert';
import * as ttm from 'azure-pipelines-task-lib/mock-test';
Expand All @@ -21,6 +22,20 @@ describe('app-store-promote L0 Suite', function () {
/* tslint:enable:no-empty */
this.timeout(parseInt(process.env.TASK_TEST_TIMEOUT) || 20000);

// Deletes the given directory after removing explicitly listed
// files that it might contain. Will fail if it contains additional files.
const deleteDirectory = (dir: string, fileNames: string[]) => {
fileNames.forEach((fileName) => {
const filePath = path.join(dir, fileName);

if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
});

fs.rmdirSync(dir);
};

it('enforce darwin', (done: Mocha.Done) => {
this.timeout(1000);

Expand Down Expand Up @@ -116,6 +131,90 @@ describe('app-store-promote L0 Suite', function () {
done();
});

it('service endpoint with api key', (done: Mocha.Done) => {
this.timeout(1000);

let tp = path.join(__dirname, 'L0ApiKeyEndPoint.js');
let tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);
const tempPath = 'test_temp_path';
const keyFileName = 'api_keyD383SF739.json';
const keyFilePath = path.join(tempPath, keyFileName);

if (!fs.existsSync(tempPath)) {
fs.mkdirSync(tempPath);
}

tr.run();

// Check api_key file first, so we can read it and clean up before other assertions
assert(fs.existsSync(keyFilePath), 'api_key.json file should have been created');

let apiKey: any = undefined;

try {
let rawdata = fs.readFileSync(keyFilePath, 'utf8');
apiKey = JSON.parse(rawdata);
} catch (e) {
assert.fail(e);
} finally {
deleteDirectory(tempPath, [keyFileName, '.taskkey']);
}

assert(tr.ran(`fastlane deliver submit_build --precheck_include_in_app_purchases false --api_key_path ${keyFilePath} -a com.microsoft.test.appId --skip_binary_upload true --skip_metadata true --skip_screenshots true --force`), 'fastlane deliver with api key should have been run.');
assert(tr.invokedToolCount === 1, 'should have run only fastlane deliver.');
assert(tr.succeeded, 'task should have succeeded');

assert(apiKey.key_id === 'D383SF739', 'key_id should be correct');
assert(apiKey.issuer_id === '6053b7fe-68a8-4acb-89be-165aa6465141', 'issuer_id should be correct');
assert(apiKey.key === 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR1RBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJIa25saGRsWWRMdQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0t', 'key should be correct');
assert(apiKey.in_house === false, 'in_house should be correct');
assert(apiKey.is_key_content_base64 === true, 'is_key_content_base64 should be correct');

done();
});

it('api key with deliver', (done: Mocha.Done) => {
this.timeout(1000);

let tp = path.join(__dirname, 'L0ApiKeyDeliver.js');
let tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);
const tempPath = 'test_temp_path';
const keyFileName = 'api_keyD383SF739.json';
const keyFilePath = path.join(tempPath, keyFileName);

if (!fs.existsSync(tempPath)) {
fs.mkdirSync(tempPath);
}

tr.run();

// Check api_key file first, so we can read it and clean up before other assertions
assert(fs.existsSync(keyFilePath), 'api_key.json file should have been created');

let apiKey: any = undefined;

try {
let rawdata = fs.readFileSync(keyFilePath, 'utf8');
apiKey = JSON.parse(rawdata);
} catch (e) {
assert.fail(e);
} finally {
deleteDirectory(tempPath, [keyFileName, '.taskkey']);
}

assert(tr.ran(`fastlane deliver submit_build --precheck_include_in_app_purchases false --api_key_path ${keyFilePath} -a com.microsoft.test.appId --skip_binary_upload true --skip_metadata true --skip_screenshots true --automatic_release --force`), 'fastlane deliver with api key should have been run.');
assert(tr.invokedToolCount === 3, 'should have run gem install, gem update and fastlane deliver.');
assert(tr.succeeded, 'task should have succeeded');

assert(apiKey.key_id === 'D383SF739', 'key_id should be correct');
assert(apiKey.issuer_id === '6053b7fe-68a8-4acb-89be-165aa6465141', 'issuer_id should be correct');
assert(apiKey.key === 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR1RBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJIa25saGRsWWRMdQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tLS0tLUVORCBQUklWQVRFIEtFWS0tLS0t', 'key should be correct');
assert(apiKey.in_house === false, 'in_house should be correct');
assert(apiKey.is_key_content_base64 === true, 'is_key_content_base64 should be correct');

done();
});

it('app specific password', (done: Mocha.Done) => {
this.timeout(1000);

Expand Down
69 changes: 69 additions & 0 deletions Tasks/AppStorePromote/Tests/L0ApiKeyDeliver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';

import ma = require('azure-pipelines-task-lib/mock-answer');
import tmrm = require('azure-pipelines-task-lib/mock-run');
import path = require('path');
import os = require('os');

let taskPath = path.join(__dirname, '..', 'app-store-promote.js');
let tmr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath);

tmr.setInput('authType', 'ApiKey');
tmr.setInput('apiKeyId', 'D383SF739');
tmr.setInput('apiKeyIssuerId', '6053b7fe-68a8-4acb-89be-165aa6465141');
tmr.setInput('apitoken', 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR1RBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJIa25saGRsWWRMdQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tLS0tLUVORCBQUklWQVRFIEtFWS0tLS0t');
tmr.setInput('appIdentifier', 'com.microsoft.test.appId');
tmr.setInput('chooseBuild', 'latest');
tmr.setInput('shouldAutoRelease', 'true');
tmr.setInput('installFastlane', 'true');
tmr.setInput('fastlaneToolsVersion', 'LatestVersion');

process.env['AGENT_TEMPDIRECTORY'] = 'test_temp_path';
// Keeps the API key file from being deleted, so we can inspect it in our test
process.env['DEBUG_API_KEY_FILE'] = 'true';
process.env['MOCK_NORMALIZE_SLASHES'] = 'true';
process.env['HOME'] = '/usr/bin';
let gemCache: string = '/usr/bin/.gem-cache';

//construct a string that is JSON, call JSON.parse(string), send that to ma.TaskLibAnswers
let myAnswers: string = `{
"which": {
"ruby": "/usr/bin/ruby",
"gem": "/usr/bin/gem",
"fastlane": "/usr/bin/fastlane"
},
"checkPath" : {
"/usr/bin/ruby": true,
"/usr/bin/gem": true,
"/usr/bin/fastlane": true
},
"exec": {
"/usr/bin/gem install fastlane": {
"code": 0,
"stdout": "1 gem installed"
},
"/usr/bin/gem update fastlane -i ${gemCache}": {
"code": 0,
"stdout": "1 gem installed"
},
"fastlane deliver submit_build --precheck_include_in_app_purchases false --api_key_path test_temp_path/api_keyD383SF739.json -a com.microsoft.test.appId --skip_binary_upload true --skip_metadata true --skip_screenshots true --automatic_release --force": {
"code": 0,
"stdout": "consider it delivered!"
}
}
}`;
let json: any = JSON.parse(myAnswers);
// Cast the json blob into a TaskLibAnswers
tmr.setAnswers(<ma.TaskLibAnswers>json);

// This is how you can mock NPM packages...
os.platform = () => {
return 'darwin';
};
tmr.registerMock('os', os);

tmr.run();
57 changes: 57 additions & 0 deletions Tasks/AppStorePromote/Tests/L0ApiKeyEndPoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';

import ma = require('azure-pipelines-task-lib/mock-answer');
import tmrm = require('azure-pipelines-task-lib/mock-run');
import path = require('path');
import os = require('os');

let taskPath = path.join(__dirname, '..', 'app-store-promote.js');
let tmr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath);

process.env['ENDPOINT_AUTH_MyServiceEndpoint'] = '{ "parameters": {"apiKeyId": "D383SF739", "apiKeyIssuerId": "6053b7fe-68a8-4acb-89be-165aa6465141", "apitoken": "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR1RBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJIa25saGRsWWRMdQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0t" }, "scheme": "Token" }';

tmr.setInput('authType', 'ServiceEndpoint');
tmr.setInput('serviceEndpoint', 'MyServiceEndpoint');
tmr.setInput('chooseBuild', 'Latest');
tmr.setInput('appIdentifier', 'com.microsoft.test.appId');

process.env['MOCK_NORMALIZE_SLASHES'] = 'true';
process.env['HOME'] = '/usr/bin';
process.env['AGENT_TEMPDIRECTORY'] = 'test_temp_path';
// Keeps the API key file from being deleted, so we can inspect it in our test
process.env['DEBUG_API_KEY_FILE'] = 'true';

//construct a string that is JSON, call JSON.parse(string), send that to ma.TaskLibAnswers
let myAnswers: string = `{
"which": {
"ruby": "/usr/bin/ruby",
"gem": "/usr/bin/gem",
"fastlane": "/usr/bin/fastlane"
},
"checkPath" : {
"/usr/bin/ruby": true,
"/usr/bin/gem": true,
"/usr/bin/fastlane": true
},
"exec": {
"fastlane deliver submit_build --precheck_include_in_app_purchases false --api_key_path test_temp_path/api_keyD383SF739.json -a com.microsoft.test.appId --skip_binary_upload true --skip_metadata true --skip_screenshots true --force": {
"code": 0,
"stdout": "consider it uploaded!"
}
}
}`;
let json: any = JSON.parse(myAnswers);
// Cast the json blob into a TaskLibAnswers
tmr.setAnswers(<ma.TaskLibAnswers>json);

// This is how you can mock NPM packages...
os.platform = () => {
return 'darwin';
};
tmr.registerMock('os', os);

tmr.run();
Loading

0 comments on commit d47166e

Please sign in to comment.