Skip to content

Commit 0b1e750

Browse files
committed
feat: support accounts with two-factor authentication on publish
1 parent 15fdf19 commit 0b1e750

11 files changed

+206
-50
lines changed

lib/commands/appstore-list.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ 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,
@@ -31,7 +32,12 @@ export class ListiOSApps implements ICommand {
3132
password = await this.$prompter.getPassword("Apple ID password");
3233
}
3334

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

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

lib/commands/appstore-upload.ts

Lines changed: 13 additions & 2 deletions
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+
ensureConsoleIsInteractive: 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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 3 additions & 1 deletion
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

Lines changed: 9 additions & 1 deletion
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

Lines changed: 3 additions & 1 deletion
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

Lines changed: 4 additions & 5 deletions
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

Lines changed: 2 additions & 2 deletions
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
}
Lines changed: 134 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isInteractive } from "../../common/helpers";
2+
13
export class ApplePortalSessionService implements IApplePortalSessionService {
24
private loginConfigEndpoint = "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com";
35
private defaultLoginConfig = {
@@ -7,42 +9,33 @@ export class ApplePortalSessionService implements IApplePortalSessionService {
79

810
constructor(
911
private $applePortalCookieService: IApplePortalCookieService,
12+
private $errors: IErrors,
1013
private $httpClient: Server.IHttpClient,
11-
private $logger: ILogger
14+
private $logger: ILogger,
15+
private $prompter: IPrompter
1216
) { }
1317

14-
public async createUserSession(credentials: ICredentials): Promise<IApplePortalUserDetail> {
15-
const loginConfig = await this.getLoginConfig();
16-
const loginUrl = `${loginConfig.authServiceUrl}/auth/signin`;
17-
const loginResponse = await this.$httpClient.httpRequest({
18-
url: loginUrl,
19-
method: "POST",
20-
body: JSON.stringify({
21-
accountName: credentials.username,
22-
password: credentials.password,
23-
rememberMe: true
24-
}),
25-
headers: {
26-
'Content-Type': 'application/json',
27-
'X-Requested-With': 'XMLHttpRequest',
28-
'X-Apple-Widget-Key': loginConfig.authServiceKey,
29-
'Accept': 'application/json, text/javascript'
30-
}
31-
});
32-
33-
this.$applePortalCookieService.updateUserSessionCookie(loginResponse.headers["set-cookie"]);
18+
public async createUserSession(credentials: ICredentials, opts?: IAppleCreateUserSessionOptions): Promise<IApplePortalUserDetail> {
19+
const loginResult = await this.login(credentials, opts);
3420

35-
const sessionResponse = await this.$httpClient.httpRequest({
36-
url: "https://appstoreconnect.apple.com/olympus/v1/session",
37-
method: "GET",
38-
headers: {
39-
'Cookie': this.$applePortalCookieService.getUserSessionCookie()
21+
if (!opts || !opts.sessionBase64) {
22+
if (loginResult.isTwoFactorAuthenticationEnabled) {
23+
const authServiceKey = (await this.getLoginConfig()).authServiceKey;
24+
await this.handleTwoFactorAuthentication(loginResult.scnt, loginResult.xAppleIdSessionId, authServiceKey);
4025
}
41-
});
4226

43-
this.$applePortalCookieService.updateUserSessionCookie(sessionResponse.headers["set-cookie"]);
27+
const sessionResponse = await this.$httpClient.httpRequest({
28+
url: "https://appstoreconnect.apple.com/olympus/v1/session",
29+
method: "GET",
30+
headers: {
31+
'Cookie': this.$applePortalCookieService.getUserSessionCookie()
32+
}
33+
});
34+
35+
this.$applePortalCookieService.updateUserSessionCookie(sessionResponse.headers["set-cookie"]);
36+
}
4437

45-
const userDetailResponse = await this.$httpClient.httpRequest({
38+
const userDetailsResponse = await this.$httpClient.httpRequest({
4639
url: "https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/user/detail",
4740
method: "GET",
4841
headers: {
@@ -51,9 +44,12 @@ export class ApplePortalSessionService implements IApplePortalSessionService {
5144
}
5245
});
5346

54-
this.$applePortalCookieService.updateUserSessionCookie(userDetailResponse.headers["set-cookie"]);
47+
this.$applePortalCookieService.updateUserSessionCookie(userDetailsResponse.headers["set-cookie"]);
5548

56-
return JSON.parse(userDetailResponse.body).data;
49+
const userdDetails = JSON.parse(userDetailsResponse.body).data;
50+
const result = { ...userdDetails, ...loginResult, userSessionCookie: this.$applePortalCookieService.getUserSessionCookie() };
51+
52+
return result;
5753
}
5854

5955
public async createWebSession(contentProviderId: number, dsId: string): Promise<string> {
@@ -79,6 +75,72 @@ export class ApplePortalSessionService implements IApplePortalSessionService {
7975
return webSessionCookie;
8076
}
8177

78+
private async login(credentials: ICredentials, opts?: IAppleCreateUserSessionOptions): Promise<IAppleLoginResult> {
79+
const result = {
80+
scnt: <string>null,
81+
xAppleIdSessionId: <string>null,
82+
isTwoFactorAuthenticationEnabled: false,
83+
areCredentialsValid: true
84+
};
85+
86+
if (opts && opts.sessionBase64) {
87+
const decodedSession = Buffer.from(opts.sessionBase64, "base64").toString("utf8");
88+
89+
this.$applePortalCookieService.updateUserSessionCookie([decodedSession]);
90+
91+
result.isTwoFactorAuthenticationEnabled = decodedSession.indexOf("DES") > -1;
92+
} else {
93+
try {
94+
await this.loginCore(credentials);
95+
} catch (err) {
96+
const statusCode = err && err.response && err.response.statusCode;
97+
result.areCredentialsValid = statusCode !== 401 && statusCode !== 403;
98+
result.isTwoFactorAuthenticationEnabled = statusCode === 409;
99+
if (result.isTwoFactorAuthenticationEnabled && opts && !opts.applicationSpecificPassword) {
100+
this.$errors.failWithoutHelp(`Your account has two-factor authentication enabled but --appleApplicationSpecificPassword option is not provided.
101+
To generate an application-specific password, please go to https://appleid.apple.com/account/manage.
102+
This password will be used for the iTunes Transporter, which is used to upload your application.`);
103+
}
104+
105+
if (result.isTwoFactorAuthenticationEnabled && opts && opts.ensureConsoleIsInteractive && !isInteractive()) {
106+
this.$errors.failWithoutHelp(`Your account has two-factor authentication enabled, but your console is not interactive.
107+
For more details how to set up your environment, please execute "tns publish ios --help".`);
108+
}
109+
110+
const headers = (err && err.response && err.response.headers) || {};
111+
result.scnt = headers.scnt;
112+
result.xAppleIdSessionId = headers['x-apple-id-session-id'];
113+
}
114+
}
115+
116+
return result;
117+
}
118+
119+
private async loginCore(credentials: ICredentials): Promise<void> {
120+
const loginConfig = await this.getLoginConfig();
121+
const loginUrl = `${loginConfig.authServiceUrl}/auth/signin`;
122+
const headers = {
123+
'Content-Type': 'application/json',
124+
'X-Requested-With': 'XMLHttpRequest',
125+
'X-Apple-Widget-Key': loginConfig.authServiceKey,
126+
'Accept': 'application/json, text/javascript'
127+
};
128+
const body = JSON.stringify({
129+
accountName: credentials.username,
130+
password: credentials.password,
131+
rememberMe: true
132+
});
133+
134+
const loginResponse = await this.$httpClient.httpRequest({
135+
url: loginUrl,
136+
method: "POST",
137+
body,
138+
headers
139+
});
140+
141+
this.$applePortalCookieService.updateUserSessionCookie(loginResponse.headers["set-cookie"]);
142+
}
143+
82144
private async getLoginConfig(): Promise<{authServiceUrl: string, authServiceKey: string}> {
83145
let config = null;
84146

@@ -91,5 +153,46 @@ export class ApplePortalSessionService implements IApplePortalSessionService {
91153

92154
return config || this.defaultLoginConfig;
93155
}
156+
157+
private async handleTwoFactorAuthentication(scnt: string, xAppleIdSessionId: string, authServiceKey: string): Promise<void> {
158+
const headers = {
159+
'scnt': scnt,
160+
'X-Apple-Id-Session-Id': xAppleIdSessionId,
161+
'X-Apple-Widget-Key': authServiceKey,
162+
'Accept': 'application/json'
163+
};
164+
const authResponse = await this.$httpClient.httpRequest({
165+
url: "https://idmsa.apple.com/appleauth/auth",
166+
method: "GET",
167+
headers
168+
});
169+
170+
const data = JSON.parse(authResponse.body);
171+
if (data.trustedPhoneNumbers && data.trustedPhoneNumbers.length) {
172+
const parsedAuthResponse = JSON.parse(authResponse.body);
173+
const token = await this.$prompter.getString(`Please enter the ${parsedAuthResponse.securityCode.length} digit code`, { allowEmpty: false });
174+
175+
await this.$httpClient.httpRequest({
176+
url: `https://idmsa.apple.com/appleauth/auth/verify/trusteddevice/securitycode`,
177+
method: "POST",
178+
body: JSON.stringify({
179+
securityCode: {
180+
code: token.toString()
181+
}
182+
}),
183+
headers: { ...headers, 'Content-Type': "application/json" }
184+
});
185+
186+
const authTrustResponse = await this.$httpClient.httpRequest({
187+
url: "https://idmsa.apple.com/appleauth/auth/2sv/trust",
188+
method: "GET",
189+
headers
190+
});
191+
192+
this.$applePortalCookieService.updateUserSessionCookie(authTrustResponse.headers["set-cookie"]);
193+
} else {
194+
this.$errors.failWithoutHelp(`Although response from Apple indicated activated Two-step Verification or Two-factor Authentication, NativeScript CLI don't know how to handle this response: ${data}`);
195+
}
196+
}
94197
}
95198
$injector.register("applePortalSessionService", ApplePortalSessionService);

0 commit comments

Comments
 (0)