Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🎉 New features

- [eas-cli] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials (for example, the submission key) to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk))

### 🐛 Bug fixes

### 🧹 Chores
Expand Down
80 changes: 80 additions & 0 deletions packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import path from 'path';
import { UserRole } from '@expo/apple-utils';

import { formatAppleTeam } from './AppleTeamFormatting';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
import Log, { learnMore } from '../../../log';
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
import { fromNow } from '../../../utils/date';
Expand All @@ -14,7 +16,11 @@ import {
getCredentialsFromUserAsync,
shouldAutoGenerateCredentialsAsync,
} from '../../utils/promptForCredentials';
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { AscApiKey } from '../appstore/Credentials.types';
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
import { hasAscEnvVars } from '../appstore/resolveCredentials';
import {
AscApiKeyPath,
MinimalAscApiKey,
Expand Down Expand Up @@ -277,3 +283,77 @@ export function formatAscApiKey(ascApiKey: AppStoreConnectApiKeyFragment): strin
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago`);
return line;
}

export async function resolveAscApiKeyForAppCredentialsAsync({
graphqlClient,
app,
}: {
graphqlClient: ExpoGraphqlClient;
app: AppLookupParams;
}): Promise<{
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
} | null> {
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
if (!ascKeyFragment) {
return null;
}
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
return {
ascApiKey: {
keyP8: fullKey.keyP8,
keyId: fullKey.keyIdentifier,
issuerId: fullKey.issuerIdentifier,
},
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
};
}

/**
* Best-effort, side-effecting helper that ensures `ctx.appStore` is authenticated
* with an App Store Connect API key when running in non-interactive mode.
*
* Returns true if `ctx.appStore.authCtx` is set after the call, false otherwise.
* Never throws.
*/
export async function tryAuthenticateAppStoreWithEasAscApiKeyAsync(
ctx: CredentialsContext,
app: AppLookupParams,
teamType: AppleTeamType
): Promise<boolean> {
if (ctx.appStore.authCtx) {
return true;
}
try {
if (hasAscEnvVars()) {
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
teamType,
});
return !!ctx.appStore.authCtx;
}
const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
graphqlClient: ctx.graphqlClient,
app,
});
if (!resolvedKey) {
return false;
}
Log.log('Using App Store Connect API Key from EAS credentials service.');
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
ascApiKey: resolvedKey.ascApiKey,
teamId: resolvedKey.teamId,
teamName: resolvedKey.teamName,
teamType,
});
return !!ctx.appStore.authCtx;
} catch (err: any) {
Log.warn(
`Failed to authenticate with the App Store Connect API key from EAS credentials service: ${err.message ?? err}`
);
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes';
import { AuthCtx } from '../appstore/authenticateTypes';
import { Target } from '../types';

export class ConfigureProvisioningProfile {
Expand All @@ -34,12 +34,9 @@ export class ConfigureProvisioningProfile {
throw new ForbidCredentialModificationError(
'Remove the --freeze-credentials flag to configure a Provisioning Profile.'
);
} else if (
ctx.nonInteractive &&
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
) {
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
throw new InsufficientAuthenticationNonInteractiveError(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for this app on EAS. ${learnMore(
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
)}`
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { ProvisioningProfile } from '../appstore/Credentials.types';
import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes';
import { AuthCtx } from '../appstore/authenticateTypes';
import { provisioningProfileSchema } from '../credentials';
import { Target } from '../types';

Expand All @@ -30,12 +30,9 @@ export class CreateProvisioningProfile {
throw new ForbidCredentialModificationError(
'Run this command again without the --freeze-credentials flag in order to generate a new Provisioning Profile.'
);
} else if (
ctx.nonInteractive &&
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
) {
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
throw new InsufficientAuthenticationNonInteractiveError(
`In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
`In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for this app on EAS. ${learnMore(
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
)}`
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
filterDevicesForApplePlatform,
formatDeviceLabel,
} from './DeviceUtils';
import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils';
import { SetUpDistributionCertificate } from './SetUpDistributionCertificate';
import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action';
import {
Expand All @@ -22,8 +23,6 @@ import {
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
import Log from '../../../log';
import { getApplePlatformFromTarget } from '../../../project/ios/target';
import {
Expand All @@ -35,13 +34,11 @@ import {
import differenceBy from '../../../utils/expodash/differenceBy';
import { CredentialsContext } from '../../context';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
import { ProvisioningProfile } from '../appstore/Credentials.types';
import { ApplePlatform } from '../appstore/constants';
import { hasAscEnvVars } from '../appstore/resolveCredentials';
import { MinimalAscApiKey } from '../credentials';
import { Target } from '../types';
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';

Expand Down Expand Up @@ -424,6 +421,7 @@ export class SetUpAdhocProvisioningProfile {
);
}

Log.log('Using App Store Connect API Key from EAS credentials service.');
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
ascApiKey: resolvedKey.ascApiKey,
Expand All @@ -436,35 +434,6 @@ export class SetUpAdhocProvisioningProfile {
}
}

async function resolveAscApiKeyForAppCredentialsAsync({
graphqlClient,
app,
}: {
graphqlClient: ExpoGraphqlClient;
app: AppLookupParams;
}): Promise<{
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
} | null> {
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
if (!ascKeyFragment) {
return null;
}

Log.log('Using App Store Connect API Key from EAS credentials service.');
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
return {
ascApiKey: {
keyP8: fullKey.keyP8,
keyId: fullKey.keyIdentifier,
issuerId: fullKey.issuerIdentifier,
},
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
};
}

export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean {
const setA = new Set(udidsA);
const setB = new Set(udidsB);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import nullthrows from 'nullthrows';

import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from './AscApiKeyUtils';
import {
assignBuildCredentialsAsync,
getBuildCredentialsAsync,
Expand All @@ -15,7 +16,7 @@ import {
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import { learnMore } from '../../../log';
import Log, { learnMore } from '../../../log';
import { getApplePlatformFromTarget } from '../../../project/ios/target';
import { confirmAsync } from '../../../prompts';
import { CredentialsContext } from '../../context';
Expand All @@ -25,7 +26,8 @@ import {
} from '../../errors';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
import { AuthenticationMode } from '../appstore/authenticateTypes';
import { resolveAppleTeamTypeFromEnvironment } from '../appstore/resolveCredentials';
import { AppleTeamType } from '../appstore/authenticateTypes';
import { Target } from '../types';
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';

Expand Down Expand Up @@ -102,21 +104,40 @@ export class SetUpProvisioningProfile {
this.app,
this.distributionType
).runAsync(ctx);
if (ctx.nonInteractive && !ctx.appStore.authCtx) {
await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
ctx,
this.app,
this.resolveTeamTypeForAuthentication()
);
}

const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
let areBuildCredentialsSetup: boolean;
try {
areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
} catch (error: any) {
if (ctx.nonInteractive) {
Log.warn(
'Skipping Provisioning Profile validation on Apple servers due to an unexpected validation error. Continuing with local validation result.'
);
Log.debug('Provisioning profile validation on Apple servers failed:', error);
areBuildCredentialsSetup = true;
} else {
throw error;
}
}
if (areBuildCredentialsSetup) {
return nullthrows(await getBuildCredentialsAsync(ctx, this.app, this.distributionType));
}
if (ctx.freezeCredentials) {
throw new ForbidCredentialModificationError(
'Provisioning profile is not configured correctly. Remove the --freeze-credentials flag to configure it.'
);
} else if (
ctx.nonInteractive &&
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
) {
}

if (ctx.nonInteractive && !ctx.appStore.authCtx) {
throw new InsufficientAuthenticationNonInteractiveError(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for this app on EAS. ${learnMore(
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
)}`
);
Expand Down Expand Up @@ -167,6 +188,24 @@ export class SetUpProvisioningProfile {
return updatedProfile;
}

/**
* The team type determines `team.inHouse`, which in turn selects the Apple profile
* type used for every subsequent profile lookup and creation (IOS_APP_INHOUSE for
* enterprise vs IOS_APP_STORE otherwise). We derive it from the distribution
* type, which is exactly what the requested operation needs: enterprise
* builds require an in-house team, other distribution types don't.
* A genuine team/distribution mismatch is rejected by Apple regardless of this value.
*/
private getDerivedTeamTypeForAuthentication(): AppleTeamType {
return this.distributionType === IosDistributionType.Enterprise
? AppleTeamType.IN_HOUSE
: AppleTeamType.COMPANY_OR_ORGANIZATION;
}
Comment on lines +191 to +203
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, I would prefer to load the apple team type along the ASC API key from the www API. However, this would be unreliable as more then 95% apple team records don't have it specified.


private resolveTeamTypeForAuthentication(): AppleTeamType {
return resolveAppleTeamTypeFromEnvironment() ?? this.getDerivedTeamTypeForAuthentication();
}

private getCurrentProfileStoreInfo(
profiles: ProvisioningProfileStoreInfo[],
currentProfile: AppleProvisioningProfileFragment
Expand Down
Loading
Loading