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 @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features

- [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))

### 🐛 Bug fixes

Expand Down
94 changes: 94 additions & 0 deletions packages/eas-cli/src/commands/submit/__tests__/internal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { getAppStoreConnectApiKeyJsonAsync } from '../internal';
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';

jest.mock('../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
AppStoreConnectApiKeyQuery: {
getByIdAsync: jest.fn(),
},
}));

describe(getAppStoreConnectApiKeyJsonAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('writes issuer_id for an inline team key', async () => {
const json = await getAppStoreConnectApiKeyJsonAsync({
iosConfig: {
ascApiKey: {
keyIdentifier: 'KEY123',
issuerIdentifier: 'ISSUER456',
keyP8: 'p8-content',
},
} as any,
graphqlClient: {} as any,
});

expect(JSON.parse(json!)).toEqual({
key_id: 'KEY123',
issuer_id: 'ISSUER456',
key: 'p8-content',
});
});

it('omits issuer_id for an inline individual key', async () => {
const json = await getAppStoreConnectApiKeyJsonAsync({
iosConfig: {
ascApiKey: {
keyIdentifier: 'KEY123',
keyP8: 'p8-content',
},
} as any,
graphqlClient: {} as any,
});

const parsed = JSON.parse(json!);
expect(parsed).toEqual({ key_id: 'KEY123', key: 'p8-content' });
expect('issuer_id' in parsed).toBe(false);
});

it('writes issuer_id for a stored team key', async () => {
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({
keyIdentifier: 'KEY123',
issuerIdentifier: 'ISSUER456',
keyP8: 'p8-content',
});

const json = await getAppStoreConnectApiKeyJsonAsync({
iosConfig: { ascApiKeyId: 'stored-key-id' } as any,
graphqlClient: {} as any,
});

expect(JSON.parse(json!)).toEqual({
key_id: 'KEY123',
issuer_id: 'ISSUER456',
key: 'p8-content',
});
});

it('omits issuer_id for a stored individual key', async () => {
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({
keyIdentifier: 'KEY123',
issuerIdentifier: undefined,
keyP8: 'p8-content',
});

const json = await getAppStoreConnectApiKeyJsonAsync({
iosConfig: { ascApiKeyId: 'stored-key-id' } as any,
graphqlClient: {} as any,
});

const parsed = JSON.parse(json!);
expect(parsed).toEqual({ key_id: 'KEY123', key: 'p8-content' });
expect('issuer_id' in parsed).toBe(false);
});

it('returns null without key input', async () => {
const json = await getAppStoreConnectApiKeyJsonAsync({
iosConfig: {} as any,
graphqlClient: {} as any,
});

expect(json).toBeNull();
});
});
10 changes: 7 additions & 3 deletions packages/eas-cli/src/commands/submit/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,25 +183,29 @@ async function getGoogleServiceAccountKeyJsonAsync({
return null;
}

async function getAppStoreConnectApiKeyJsonAsync({
export async function getAppStoreConnectApiKeyJsonAsync({
iosConfig,
graphqlClient,
}: {
iosConfig: IosSubmissionConfigInput;
graphqlClient: ExpoGraphqlClient;
}): Promise<string | null> {
// Individual API keys have no issuer. fastlane detects them by the absence
// of the issuer_id field, so it must be omitted entirely, not set to null.
if (iosConfig.ascApiKey) {
return JSON.stringify({
key_id: iosConfig.ascApiKey.keyIdentifier,
issuer_id: iosConfig.ascApiKey.issuerIdentifier,
...(iosConfig.ascApiKey.issuerIdentifier
? { issuer_id: iosConfig.ascApiKey.issuerIdentifier }
: null),
key: iosConfig.ascApiKey.keyP8,
});
} else if (iosConfig.ascApiKeyId) {
const key = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, iosConfig.ascApiKeyId);

return JSON.stringify({
key_id: key.keyIdentifier,
issuer_id: key.issuerIdentifier,
...(key.issuerIdentifier ? { issuer_id: key.issuerIdentifier } : null),
key: key.keyP8,
});
}
Expand Down
17 changes: 17 additions & 0 deletions packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,23 @@ function filterKeysFromDifferentAppleTeam(
return keys.filter(key => !key.appleTeam || key.appleTeam?.appleTeamIdentifier === teamId);
}

export function filterOutIndividualAscApiKeys(
keys: AppStoreConnectApiKeyFragment[]
): AppStoreConnectApiKeyFragment[] {
const teamKeys = keys.filter(key => !!key.issuerIdentifier);
const hiddenCount = keys.length - teamKeys.length;
if (hiddenCount > 0) {
Log.log(
chalk.gray(
`${hiddenCount} individual API ${
hiddenCount === 1 ? 'key' : 'keys'
} hidden: individual keys are valid only for submissions.`
)
);
}
return teamKeys;
}

export function sortAscApiKeysByUpdatedAtDesc(
keys: AppStoreConnectApiKeyFragment[]
): AppStoreConnectApiKeyFragment[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '../../../utils/promptForCredentials';
import {
AppStoreApiKeyPurpose,
filterOutIndividualAscApiKeys,
getAscApiKeyName,
promptForAscApiKeyPathAsync,
provideOrGenerateAscApiKeyAsync,
Expand All @@ -32,6 +33,24 @@ afterEach(() => {
jest.mocked(fs.readFile).mockClear();
});

describe(filterOutIndividualAscApiKeys, () => {
it('removes keys without an issuer identifier and keeps the rest', () => {
const teamKey = { id: 'team', issuerIdentifier: 'issuer-id' } as any;
const individualKey = { id: 'individual', issuerIdentifier: null } as any;

expect(filterOutIndividualAscApiKeys([teamKey, individualKey])).toEqual([teamKey]);
});

it('returns all keys when none is individual', () => {
const keys = [
{ id: 'a', issuerIdentifier: 'issuer-a' },
{ id: 'b', issuerIdentifier: 'issuer-b' },
] as any[];

expect(filterOutIndividualAscApiKeys(keys)).toEqual(keys);
});
});

describe(getAscApiKeyName, () => {
// Apple enforces a 30 char limit on this name
it('produces a name under 30 chars', async () => {
Expand Down
89 changes: 56 additions & 33 deletions packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
assertUserAuthCtx,
authenticateAsync,
isUserAuthCtx,
withIndividualAscApiKeyProvisioningHint,
} from './authenticate';
import { AuthCtx, AuthenticationMode, UserAuthCtx } from './authenticateTypes';
import { ApplePlatform } from './constants';
Expand Down Expand Up @@ -80,27 +81,40 @@ export default class AppStoreApi {
return this.authCtx;
}

private async runProvisioningOperationAsync<T>(fn: (ctx: AuthCtx) => Promise<T>): Promise<T> {
const ctx = await this.ensureAuthenticatedAsync();
try {
return await fn(ctx);
} catch (error) {
throw withIndividualAscApiKeyProvisioningHint(error, ctx);
}
}

public async ensureBundleIdExistsAsync(
app: AppLookupParams,
options?: IosCapabilitiesOptions
): Promise<void> {
const ctx = await this.ensureAuthenticatedAsync();
await ensureBundleIdExistsAsync(ctx, app, options);
await this.runProvisioningOperationAsync(async ctx => {
await ensureBundleIdExistsAsync(ctx, app, options);
});
}

public async listDistributionCertificatesAsync(): Promise<DistributionCertificateStoreInfo[]> {
const ctx = await this.ensureAuthenticatedAsync();
return await listDistributionCertificatesAsync(ctx);
return await this.runProvisioningOperationAsync(
async ctx => await listDistributionCertificatesAsync(ctx)
);
}

public async createDistributionCertificateAsync(): Promise<DistributionCertificate> {
const ctx = await this.ensureAuthenticatedAsync();
return await createDistributionCertificateAsync(ctx);
return await this.runProvisioningOperationAsync(
async ctx => await createDistributionCertificateAsync(ctx)
);
}

public async revokeDistributionCertificateAsync(ids: string[]): Promise<void> {
const ctx = await this.ensureAuthenticatedAsync();
await revokeDistributionCertificateAsync(ctx, ids);
await this.runProvisioningOperationAsync(async ctx => {
await revokeDistributionCertificateAsync(ctx, ids);
});
}

public async listPushKeysAsync(): Promise<PushKeyStoreInfo[]> {
Expand All @@ -123,12 +137,14 @@ export default class AppStoreApi {
provisioningProfile: ProvisioningProfile,
distCert: DistributionCertificate
): Promise<ProvisioningProfile> {
const ctx = await this.ensureAuthenticatedAsync();
return await useExistingProvisioningProfileAsync(
ctx,
bundleIdentifier,
provisioningProfile,
distCert
return await this.runProvisioningOperationAsync(
async ctx =>
await useExistingProvisioningProfileAsync(
ctx,
bundleIdentifier,
provisioningProfile,
distCert
)
);
}

Expand All @@ -137,8 +153,10 @@ export default class AppStoreApi {
applePlatform: ApplePlatform,
profileClass?: ProfileClass
): Promise<ProvisioningProfileStoreInfo[]> {
const ctx = await this.ensureAuthenticatedAsync();
return await listProvisioningProfilesAsync(ctx, bundleIdentifier, applePlatform, profileClass);
return await this.runProvisioningOperationAsync(
async ctx =>
await listProvisioningProfilesAsync(ctx, bundleIdentifier, applePlatform, profileClass)
);
}

public async createProvisioningProfileAsync(
Expand All @@ -148,14 +166,16 @@ export default class AppStoreApi {
applePlatform: ApplePlatform,
profileClass?: ProfileClass
): Promise<ProvisioningProfile> {
const ctx = await this.ensureAuthenticatedAsync();
return await createProvisioningProfileAsync(
ctx,
bundleIdentifier,
distCert,
profileName,
applePlatform,
profileClass
return await this.runProvisioningOperationAsync(
async ctx =>
await createProvisioningProfileAsync(
ctx,
bundleIdentifier,
distCert,
profileName,
applePlatform,
profileClass
)
);
}

Expand All @@ -164,8 +184,9 @@ export default class AppStoreApi {
applePlatform: ApplePlatform,
profileClass?: ProfileClass
): Promise<void> {
const ctx = await this.ensureAuthenticatedAsync();
await revokeProvisioningProfileAsync(ctx, bundleIdentifier, applePlatform, profileClass);
await this.runProvisioningOperationAsync(async ctx => {
await revokeProvisioningProfileAsync(ctx, bundleIdentifier, applePlatform, profileClass);
});
}

public async createOrReuseAdhocProvisioningProfileAsync(
Expand All @@ -174,13 +195,15 @@ export default class AppStoreApi {
distCertSerialNumber: string,
profileType: ProfileType
): Promise<ProvisioningProfile> {
const ctx = await this.ensureAuthenticatedAsync();
return await createOrReuseAdhocProvisioningProfileAsync(
ctx,
udids,
bundleIdentifier,
distCertSerialNumber,
profileType
return await this.runProvisioningOperationAsync(
async ctx =>
await createOrReuseAdhocProvisioningProfileAsync(
ctx,
udids,
bundleIdentifier,
distCertSerialNumber,
profileType
)
);
}

Expand Down
Loading
Loading