Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages.

- [build-tools] Support individual (issuer-less) App Store Connect API keys in the `eas/upload_to_asc` step. ([#4247](https://github.com/expo/eas-cli/pull/4247) by [@szymonswierk](https://github.com/szymonswierk))
- [eas-cli] Accept individual (issuer-less) App Store Connect API keys for submissions, TestFlight setup, and metadata; block them for provisioning operations with an actionable error. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@szymonswierk](https://github.com/szymonswierk))
- [eas-cli] Ask for the App Store Connect API key type (team or individual) in the submission key prompt and skip the Issuer ID prompt for individual keys. ([#4250](https://github.com/expo/eas-cli/pull/4250) by [@szymonswierk](https://github.com/szymonswierk))

### 🐛 Bug fixes

Expand Down
36 changes: 32 additions & 4 deletions packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,27 @@ export enum AppStoreApiKeyPurpose {
ASC_APP_CONNECTION = 'EAS Connect',
}

export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Promise<AscApiKeyPath> {
export async function promptForAscApiKeyPathAsync(
ctx: CredentialsContext,
purpose: AppStoreApiKeyPurpose
): Promise<AscApiKeyPath> {
// Individual keys are valid only as submission keys. Every other purpose
// requires a team key, so the key type question is not asked there.
const isIndividualKey =
purpose === AppStoreApiKeyPurpose.SUBMISSION_SERVICE &&
!ctx.nonInteractive &&
(await promptForAscApiKeyTypeIsIndividualAsync());

const { keyId, keyP8Path } = await promptForKeyP8AndIdAsync();

if (isIndividualKey) {
Log.log(
'Individual API keys can be used for submissions, TestFlight setup, and metadata only. ' +
'They cannot manage certificates or provisioning profiles.'
);
return { keyId, keyP8Path };
}

const bestEffortIssuerId = await getBestEffortIssuerIdAsync(ctx, keyId);
if (bestEffortIssuerId) {
Log.log(`Detected Issuer ID: ${bestEffortIssuerId}`);
Expand All @@ -46,6 +64,13 @@ export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Prom
return { keyId, issuerId, keyP8Path };
}

async function promptForAscApiKeyTypeIsIndividualAsync(): Promise<boolean> {
return await selectAsync<boolean>('Which type of App Store Connect API key do you want to use?', [
{ title: 'Team key (recommended)', value: false },
{ title: 'Individual key (submissions only)', value: true },
]);
}

export async function promptForIssuerIdAsync(): Promise<string> {
Log.log(chalk.bold('An App Store Connect Issuer ID is required'));
Log.log(
Expand Down Expand Up @@ -79,7 +104,7 @@ export async function provideOrGenerateAscApiKeyAsync(
return await generateAscApiKeyAsync(ctx, purpose);
}

const userProvided = await promptForAscApiKeyAsync(ctx);
const userProvided = await promptForAscApiKeyAsync(ctx, purpose);
if (!userProvided) {
return await generateAscApiKeyAsync(ctx, purpose);
}
Expand Down Expand Up @@ -133,12 +158,15 @@ export function getAscApiKeyName(purpose: AppStoreApiKeyPurpose): string {
return nameParts.join(' ');
}

async function promptForAscApiKeyAsync(ctx: CredentialsContext): Promise<MinimalAscApiKey | null> {
async function promptForAscApiKeyAsync(
ctx: CredentialsContext,
purpose: AppStoreApiKeyPurpose
): Promise<MinimalAscApiKey | null> {
const shouldAutoGenerateCredentials = await shouldAutoGenerateCredentialsAsync(ascApiKeyIdSchema);
if (shouldAutoGenerateCredentials) {
return null;
}
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx, purpose);
const { keyP8Path, keyId, issuerId } = ascApiKeyPath;
return { keyP8: await fs.readFile(keyP8Path, 'utf-8'), keyId, issuerId };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe(getAscApiKeyName, () => {

describe(promptForAscApiKeyPathAsync, () => {
it('prompts for keyId, keyP8Path and issuerId when user is not authenticated to Apple', async () => {
jest.mocked(selectAsync).mockResolvedValueOnce(false); // team key
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
keyP8Path: '/asc-api-key.p8',
}));
Expand All @@ -81,16 +82,21 @@ describe(promptForAscApiKeyPathAsync, () => {
authCtx: null,
},
});
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
ctx,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
expect(ascApiKeyPath).toEqual({
keyId: 'test-key-id',
issuerId: 'test-issuer-id',
keyP8Path: '/asc-api-key.p8',
});
expect(selectAsync).toHaveBeenCalledTimes(1); // key type
expect(promptAsync).toHaveBeenCalledTimes(1); // keyP8Path
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(2); // keyId, issuerId
});
it('prompts for keyId, keyP8Path and detects issuerId when user is authenticated to Apple', async () => {
jest.mocked(selectAsync).mockResolvedValueOnce(false); // team key
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
keyP8Path: '/asc-api-key.p8',
}));
Expand All @@ -106,7 +112,10 @@ describe(promptForAscApiKeyPathAsync, () => {
getAscApiKeyAsync: jest.fn(() => testAscApiKey),
},
});
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
ctx,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
expect(ascApiKeyPath).toEqual({
keyId: 'test-key-id',
issuerId: 'test-issuer-id-from-apple',
Expand All @@ -116,6 +125,60 @@ describe(promptForAscApiKeyPathAsync, () => {
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(1); // keyId
expect(jest.mocked(ctx.appStore.getAscApiKeyAsync).mock.calls.length).toBe(1); // issuerId
});
it('skips the issuer prompt for an individual key in the submission flow', async () => {
jest.mocked(selectAsync).mockResolvedValueOnce(true); // individual key
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
keyP8Path: '/asc-api-key.p8',
}));
jest.mocked(getCredentialsFromUserAsync).mockImplementation(async () => ({
keyId: 'test-key-id',
}));
const getAscApiKeyAsync = jest.fn(() => testAscApiKey);
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
authCtx: testAuthCtx,
getAscApiKeyAsync,
},
});
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
ctx,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
expect(ascApiKeyPath).toEqual({
keyId: 'test-key-id',
keyP8Path: '/asc-api-key.p8',
});
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(1); // keyId only
expect(getAscApiKeyAsync).not.toHaveBeenCalled(); // no issuer detection
});
it('does not ask the key type outside the submission flow', async () => {
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
keyP8Path: '/asc-api-key.p8',
}));
jest.mocked(getCredentialsFromUserAsync).mockImplementation(async () => ({
keyId: 'test-key-id',
issuerId: 'test-issuer-id',
}));
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
authCtx: null,
},
});
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
ctx,
AppStoreApiKeyPurpose.ASC_APP_CONNECTION
);
expect(ascApiKeyPath).toEqual({
keyId: 'test-key-id',
issuerId: 'test-issuer-id',
keyP8Path: '/asc-api-key.p8',
});
expect(selectAsync).not.toHaveBeenCalled();
});
});

describe(provideOrGenerateAscApiKeyAsync, () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ export async function resolveAscApiKeyAsync(
(!!ascApiKey?.keyId || !!process.env.EXPO_ASC_KEY_ID) &&
!ascApiKey?.issuerId &&
!process.env.EXPO_ASC_ISSUER_ID;
if (keyProvidedWithoutIssuer) {
Log.log(
'No Issuer ID provided; treating the App Store Connect API key as an individual key. ' +
'Individual keys can be used for submissions, TestFlight setup, and metadata only.'
);
}
const passedIssuerId = keyProvidedWithoutIssuer
? undefined
: await getAscIssuerIdFromEnvironmentOrOptionsAsync(ascApiKey);
Expand Down
5 changes: 4 additions & 1 deletion packages/eas-cli/src/submit/ios/AscApiKeySource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ async function handlePromptSourceAsync(
ctx: SubmissionContext<Platform.IOS>,
_source: AscApiKeyPromptSource
): Promise<AscApiKeyPath> {
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx.credentialsCtx);
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
ctx.credentialsCtx,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
return await getAscApiKeyPathAsync(ctx, {
sourceType: AscApiKeySourceType.path,
path: ascApiKeyPath,
Expand Down
Loading