Skip to content

Commit 40de21d

Browse files
authored
Merge pull request #4903 from NativeScript/fatme/2fa
fix: ability to publish iOS applications for users with two-factor authentication enabled
2 parents a106c8d + b63f34e commit 40de21d

17 files changed

+296
-54
lines changed
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<% if (isJekyll) { %>---
2+
title: tns apple-login
3+
position: 5
4+
---<% } %>
5+
6+
# tns apple-login
7+
8+
### Description
9+
10+
Uses the provided Apple credentials to obtain Apple session which can be used when publishing to Apple AppStore.
11+
12+
### Commands
13+
14+
Usage | Synopsis
15+
---|---
16+
General | `$ tns apple-login [<Apple ID>] [<Password>]`
17+
18+
### Arguments
19+
20+
* `<Apple ID>` and `<Password>` are your credentials for logging into iTunes Connect.
21+
22+
<% if(isHtml) { %>s
23+
24+
### Related Commands
25+
26+
Command | Description
27+
----------|----------
28+
[appstore](appstore.html) | Lists applications registered in iTunes Connect.
29+
[appstore upload](appstore-upload.html) | Uploads project to iTunes Connect.
30+
[build](../project/testing/build.html) | Builds the project for the selected target platform and produces an application package that you can manually deploy on device or in the native emulator.
31+
[build ios](../project/testing/build-ios.html) | Builds the project for iOS and produces an APP or IPA that you can manually deploy in the iOS Simulator or on device, respectively.
32+
[deploy](../project/testing/deploy.html) | Builds and deploys the project to a connected physical or virtual device.
33+
[run](../project/testing/run.html) | Runs your project on a connected device or in the native emulator for the selected platform.
34+
[run ios](../project/testing/run-ios.html) | Runs your project on a connected iOS device or in the iOS Simulator, if configured.
35+
<% } %>

docs/man_pages/publishing/appstore-upload.md

+3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ position: 1
88
### Description
99

1010
Uploads project to iTunes Connect. The command either issues a production build and uploads it to iTunes Connect, or uses an already built package to upload.
11+
The user will be prompted interactively for verification code when two-factor authentication enabled account is used. As on non-interactive console (CI), you will not be prompt for verification code. In this case, you need to generate a login session for your apple's account in advance using `tns apple-login` command. The generated value must be provided via the `--appleSessionBase64` option and is only valid for up to a month. Meaning you'll need to create a new session every month.
1112

1213
<% if(isConsole && (isLinux || isWindows)) { %>WARNING: You can run this command only on macOS systems. To view the complete help for this command, run `$ tns help appstore upload`<% } %>
1314
<% if((isConsole && isMacOS) || isHtml) { %>
@@ -22,6 +23,8 @@ Upload package | `$ tns appstore upload [<Apple ID> [<Password>]] --ipa <Ipa Fil
2223
### Options
2324

2425
* `--ipa` - If set, will use provided .ipa file instead of building the project.
26+
* `--appleApplicationSpecificPassword` - Specifies the password for accessing the information you store in iTunes Transporter application.
27+
* `--appleSessionBase64` - The session that will be used instead of triggering a new login each time NativeScript CLI communicates with Apple's APIs.
2528

2629
### Arguments
2730

docs/man_pages/publishing/publish-ios.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ position: 3
88
### Description
99

1010
Uploads project to iTunes Connect. The command either issues a production build and uploads it to iTunes Connect, or uses an already built package to upload.
11+
The user will be prompted interactively for verification code when two-factor authentication enabled account is used. As on non-interactive console (CI), you will not be prompt for verification code. In this case, you need to generate a login session for your apple's account in advance using `tns apple-login` command. The generated value must be provided via the `--appleSessionBase64` option and is only valid for up to a month. Meaning you'll need to create a new session every month.
1112

1213
<% if(isConsole && (isLinux || isWindows)) { %>WARNING: You can run this command only on macOS systems. To view the complete help for this command, run `$ tns help publish ios`<% } %>
1314

@@ -24,7 +25,9 @@ Upload package | `$ tns publish ios [<Apple ID> [<Password>]] --ipa <Ipa File Pa
2425

2526
* `--ipa` - If set, will use provided .ipa file instead of building the project.
2627
* `--team-id` - Specified the team id for which Xcode will try to find distribution certificate and provisioning profile when exporting for AppStore submission.
27-
28+
* `--appleApplicationSpecificPassword` - Specifies the password for accessing the information you store in iTunes Transporter application.
29+
* `--appleSessionBase64` - The session that will be used instead of triggering a new login each time NativeScript CLI communicates with Apple's APIs.
30+
2831
### Arguments
2932

3033
* `<Apple ID>` and `<Password>` are your credentials for logging into iTunes Connect.

lib/bootstrap.ts

+1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ $injector.requireCommand("dev-generate-help", "./commands/generate-help");
100100
$injector.requireCommand("appstore|*list", "./commands/appstore-list");
101101
$injector.requireCommand("appstore|upload", "./commands/appstore-upload");
102102
$injector.requireCommand("publish|ios", "./commands/appstore-upload");
103+
$injector.requireCommand("apple-login", "./commands/apple-login");
103104
$injector.require("itmsTransporterService", "./services/itmstransporter-service");
104105

105106
$injector.requireCommand("setup|*", "./commands/setup");

lib/commands/apple-login.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { StringCommandParameter } from "../common/command-params";
2+
3+
export class AppleLogin implements ICommand {
4+
public allowedParameters: ICommandParameter[] = [new StringCommandParameter(this.$injector), new StringCommandParameter(this.$injector)];
5+
6+
constructor(
7+
private $applePortalSessionService: IApplePortalSessionService,
8+
private $errors: IErrors,
9+
private $injector: IInjector,
10+
private $logger: ILogger,
11+
private $prompter: IPrompter
12+
) { }
13+
14+
public async execute(args: string[]): Promise<void> {
15+
let username = args[0];
16+
if (!username) {
17+
username = await this.$prompter.getString("Apple ID", { allowEmpty: false });
18+
}
19+
20+
let password = args[1];
21+
if (!password) {
22+
password = await this.$prompter.getPassword("Apple ID password");
23+
}
24+
25+
const user = await this.$applePortalSessionService.createUserSession({ username, password });
26+
if (!user.areCredentialsValid) {
27+
this.$errors.failWithoutHelp(`Invalid username and password combination. Used '${username}' as the username.`);
28+
}
29+
30+
const output = Buffer.from(user.userSessionCookie).toString("base64");
31+
this.$logger.info(output);
32+
}
33+
}
34+
$injector.registerCommand("apple-login", AppleLogin);

lib/commands/appstore-list.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ export class ListiOSApps implements ICommand {
66

77
constructor(private $injector: IInjector,
88
private $applePortalApplicationService: IApplePortalApplicationService,
9+
private $applePortalSessionService: IApplePortalSessionService,
910
private $logger: ILogger,
1011
private $projectData: IProjectData,
1112
private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants,
1213
private $platformValidationService: IPlatformValidationService,
1314
private $errors: IErrors,
14-
private $prompter: IPrompter) {
15+
private $prompter: IPrompter,
16+
private $options: IOptions) {
1517
this.$projectData.initializeProjectData();
1618
}
1719

@@ -31,7 +33,14 @@ export class ListiOSApps implements ICommand {
3133
password = await this.$prompter.getPassword("Apple ID password");
3234
}
3335

34-
const applications = await this.$applePortalApplicationService.getApplications({ username, password });
36+
const user = await this.$applePortalSessionService.createUserSession({ username, password }, {
37+
sessionBase64: this.$options.appleSessionBase64,
38+
});
39+
if (!user.areCredentialsValid) {
40+
this.$errors.failWithoutHelp(`Invalid username and password combination. Used '${username}' as the username.`);
41+
}
42+
43+
const applications = await this.$applePortalApplicationService.getApplications(user);
3544

3645
if (!applications || !applications.length) {
3746
this.$logger.info("Seems you don't have any applications yet.");

lib/commands/appstore-upload.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export class PublishIOS implements ICommand {
88
new StringCommandParameter(this.$injector), new StringCommandParameter(this.$injector)];
99

1010
constructor(
11+
private $applePortalSessionService: IApplePortalSessionService,
1112
private $injector: IInjector,
1213
private $itmsTransporterService: IITMSTransporterService,
1314
private $logger: ILogger,
@@ -38,6 +39,15 @@ export class PublishIOS implements ICommand {
3839
password = await this.$prompter.getPassword("Apple ID password");
3940
}
4041

42+
const user = await this.$applePortalSessionService.createUserSession({ username, password }, {
43+
applicationSpecificPassword: this.$options.appleApplicationSpecificPassword,
44+
sessionBase64: this.$options.appleSessionBase64,
45+
requireInteractiveConsole: true
46+
});
47+
if (!user.areCredentialsValid) {
48+
this.$errors.failWithoutHelp(`Invalid username and password combination. Used '${username}' as the username.`);
49+
}
50+
4151
if (!mobileProvisionIdentifier && !ipaFilePath) {
4252
this.$logger.warn("No mobile provision identifier set. A default mobile provision will be used. You can set one in app/App_Resources/iOS/build.xcconfig");
4353
}
@@ -69,8 +79,9 @@ export class PublishIOS implements ICommand {
6979
}
7080

7181
await this.$itmsTransporterService.upload({
72-
username,
73-
password,
82+
credentials: { username, password },
83+
user,
84+
applicationSpecificPassword: this.$options.appleApplicationSpecificPassword,
7485
ipaFilePath,
7586
shouldExtractIpa: !!this.$options.ipa,
7687
verboseLogging: this.$logger.getLevel() === "TRACE"

lib/common/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export class HttpStatusCodes {
9595
static NOT_MODIFIED = 304;
9696
static PAYMENT_REQUIRED = 402;
9797
static PROXY_AUTHENTICATION_REQUIRED = 407;
98+
static CONFLICTING_RESOURCE = 409;
9899
}
99100

100101
export const HttpProtocolToPort: IDictionary<number> = {

lib/common/http-client.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,9 @@ private defaultUserAgent: string;
255255
this.$logger.error(`You can run ${EOL}\t${clientNameLowerCase} proxy set <url> <username> <password>.${EOL}In order to supply ${clientNameLowerCase} with the credentials needed.`);
256256
return "Your proxy requires authentication.";
257257
} else if (statusCode === HttpStatusCodes.PAYMENT_REQUIRED) {
258-
return util.format("Your subscription has expired.");
258+
return "Your subscription has expired.";
259+
} else if (statusCode === HttpStatusCodes.CONFLICTING_RESOURCE) {
260+
return "The request conflicts with the current state of the server.";
259261
} else {
260262
this.$logger.trace("Request was unsuccessful. Server returned: ", body);
261263
try {

lib/declarations.d.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,8 @@ interface IOptions extends IRelease, IDeviceIdentifier, IJustLaunch, IAvd, IAvai
568568
analyticsLogFile: string;
569569
performance: Object;
570570
cleanupLogFile: string;
571+
appleApplicationSpecificPassword: string;
572+
appleSessionBase64: string;
571573
}
572574

573575
interface IEnvOptions {
@@ -609,7 +611,13 @@ interface IAndroidResourcesMigrationService {
609611
/**
610612
* Describes properties needed for uploading a package to iTunes Connect
611613
*/
612-
interface IITMSData extends ICredentials {
614+
interface IITMSData {
615+
credentials: ICredentials;
616+
617+
user: IApplePortalUserDetail;
618+
619+
applicationSpecificPassword: string;
620+
613621
/**
614622
* Path to a .ipa file which will be uploaded.
615623
* @type {string}

lib/options.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ export class Options {
145145
hooks: { type: OptionType.Boolean, default: true, hasSensitiveValue: false },
146146
link: { type: OptionType.Boolean, default: false, hasSensitiveValue: false },
147147
aab: { type: OptionType.Boolean, hasSensitiveValue: false },
148-
performance: { type: OptionType.Object, hasSensitiveValue: true }
148+
performance: { type: OptionType.Object, hasSensitiveValue: true },
149+
appleApplicationSpecificPassword: { type: OptionType.String, hasSensitiveValue: true },
150+
appleSessionBase64: { type: OptionType.String, hasSensitiveValue: true },
149151
};
150152
}
151153

lib/services/apple-portal/apple-portal-application-service.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ export class ApplePortalApplicationService implements IApplePortalApplicationSer
55
private $httpClient: Server.IHttpClient
66
) { }
77

8-
public async getApplications(credentials: ICredentials): Promise<IApplePortalApplicationSummary[]> {
8+
public async getApplications(user: IApplePortalUserDetail): Promise<IApplePortalApplicationSummary[]> {
99
let result: IApplePortalApplicationSummary[] = [];
1010

11-
const user = await this.$applePortalSessionService.createUserSession(credentials);
1211
for (const account of user.associatedAccounts) {
1312
const contentProviderId = account.contentProvider.contentProviderId;
1413
const dsId = user.sessionToken.dsId;
@@ -36,10 +35,10 @@ export class ApplePortalApplicationService implements IApplePortalApplicationSer
3635
return JSON.parse(response.body).data;
3736
}
3837

39-
public async getApplicationByBundleId(credentials: ICredentials, bundleId: string): Promise<IApplePortalApplicationSummary> {
40-
const applications = await this.getApplications(credentials);
38+
public async getApplicationByBundleId(user: IApplePortalUserDetail, bundleId: string): Promise<IApplePortalApplicationSummary> {
39+
const applications = await this.getApplications(user);
4140
if (!applications || !applications.length) {
42-
this.$errors.failWithoutHelp(`Cannot find any registered applications for Apple ID ${credentials.username} in iTunes Connect.`);
41+
this.$errors.failWithoutHelp(`Cannot find any registered applications for Apple ID ${user.userName} in iTunes Connect.`);
4342
}
4443

4544
const application = _.find(applications, app => app.bundleId === bundleId);

lib/services/apple-portal/apple-portal-cookie-service.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export class ApplePortalCookieService implements IApplePortalCookieService {
22
private userSessionCookies: IStringDictionary = {};
3-
private validUserSessionCookieNames = ["myacinfo", "dqsid", "itctx", "itcdq", "acn01"];
3+
private validUserSessionCookieNames = ["myacinfo", "dqsid", "itctx", "itcdq", "acn01", "DES"];
44
private validWebSessionCookieNames = ["wosid", "woinst", "itctx"];
55

66
public getWebSessionCookie(cookiesData: string[]): string {
@@ -29,7 +29,7 @@ export class ApplePortalCookieService implements IApplePortalCookieService {
2929
for (const cookie of parts) {
3030
const trimmedCookie = cookie.trim();
3131
const [cookieKey, cookieValue] = trimmedCookie.split("=");
32-
if (_.includes(validCookieNames, cookieKey)) {
32+
if (_.includes(validCookieNames, cookieKey) || _.some(validCookieNames, validCookieName => cookieKey.startsWith(validCookieName))) {
3333
result[cookieKey] = { key: cookieKey, value: cookieValue, cookie: trimmedCookie };
3434
}
3535
}

0 commit comments

Comments
 (0)