diff --git a/CHANGELOG.md b/CHANGELOG.md index b922b03317..7f9c50bf2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/eas-cli/src/commands/submit/__tests__/internal.test.ts b/packages/eas-cli/src/commands/submit/__tests__/internal.test.ts new file mode 100644 index 0000000000..ed59798071 --- /dev/null +++ b/packages/eas-cli/src/commands/submit/__tests__/internal.test.ts @@ -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(); + }); +}); diff --git a/packages/eas-cli/src/commands/submit/internal.ts b/packages/eas-cli/src/commands/submit/internal.ts index d650ef7307..a8fdd803d9 100644 --- a/packages/eas-cli/src/commands/submit/internal.ts +++ b/packages/eas-cli/src/commands/submit/internal.ts @@ -183,17 +183,21 @@ async function getGoogleServiceAccountKeyJsonAsync({ return null; } -async function getAppStoreConnectApiKeyJsonAsync({ +export async function getAppStoreConnectApiKeyJsonAsync({ iosConfig, graphqlClient, }: { iosConfig: IosSubmissionConfigInput; graphqlClient: ExpoGraphqlClient; }): Promise { + // 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) { @@ -201,7 +205,7 @@ async function getAppStoreConnectApiKeyJsonAsync({ return JSON.stringify({ key_id: key.keyIdentifier, - issuer_id: key.issuerIdentifier, + ...(key.issuerIdentifier ? { issuer_id: key.issuerIdentifier } : null), key: key.keyP8, }); } diff --git a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts index fe5e09bd02..96b3c5b9ce 100644 --- a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts +++ b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts @@ -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[] { diff --git a/packages/eas-cli/src/credentials/ios/actions/__tests__/AscApiKeyUtils-test.ts b/packages/eas-cli/src/credentials/ios/actions/__tests__/AscApiKeyUtils-test.ts index 46c8699c06..4c6a6c257e 100644 --- a/packages/eas-cli/src/credentials/ios/actions/__tests__/AscApiKeyUtils-test.ts +++ b/packages/eas-cli/src/credentials/ios/actions/__tests__/AscApiKeyUtils-test.ts @@ -11,6 +11,7 @@ import { } from '../../../utils/promptForCredentials'; import { AppStoreApiKeyPurpose, + filterOutIndividualAscApiKeys, getAscApiKeyName, promptForAscApiKeyPathAsync, provideOrGenerateAscApiKeyAsync, @@ -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 () => { diff --git a/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts b/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts index dfa15d7d56..184ab26390 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts @@ -22,6 +22,7 @@ import { assertUserAuthCtx, authenticateAsync, isUserAuthCtx, + withIndividualAscApiKeyProvisioningHint, } from './authenticate'; import { AuthCtx, AuthenticationMode, UserAuthCtx } from './authenticateTypes'; import { ApplePlatform } from './constants'; @@ -80,27 +81,40 @@ export default class AppStoreApi { return this.authCtx; } + private async runProvisioningOperationAsync(fn: (ctx: AuthCtx) => Promise): Promise { + const ctx = await this.ensureAuthenticatedAsync(); + try { + return await fn(ctx); + } catch (error) { + throw withIndividualAscApiKeyProvisioningHint(error, ctx); + } + } + public async ensureBundleIdExistsAsync( app: AppLookupParams, options?: IosCapabilitiesOptions ): Promise { - const ctx = await this.ensureAuthenticatedAsync(); - await ensureBundleIdExistsAsync(ctx, app, options); + await this.runProvisioningOperationAsync(async ctx => { + await ensureBundleIdExistsAsync(ctx, app, options); + }); } public async listDistributionCertificatesAsync(): Promise { - const ctx = await this.ensureAuthenticatedAsync(); - return await listDistributionCertificatesAsync(ctx); + return await this.runProvisioningOperationAsync( + async ctx => await listDistributionCertificatesAsync(ctx) + ); } public async createDistributionCertificateAsync(): Promise { - const ctx = await this.ensureAuthenticatedAsync(); - return await createDistributionCertificateAsync(ctx); + return await this.runProvisioningOperationAsync( + async ctx => await createDistributionCertificateAsync(ctx) + ); } public async revokeDistributionCertificateAsync(ids: string[]): Promise { - const ctx = await this.ensureAuthenticatedAsync(); - await revokeDistributionCertificateAsync(ctx, ids); + await this.runProvisioningOperationAsync(async ctx => { + await revokeDistributionCertificateAsync(ctx, ids); + }); } public async listPushKeysAsync(): Promise { @@ -123,12 +137,14 @@ export default class AppStoreApi { provisioningProfile: ProvisioningProfile, distCert: DistributionCertificate ): Promise { - const ctx = await this.ensureAuthenticatedAsync(); - return await useExistingProvisioningProfileAsync( - ctx, - bundleIdentifier, - provisioningProfile, - distCert + return await this.runProvisioningOperationAsync( + async ctx => + await useExistingProvisioningProfileAsync( + ctx, + bundleIdentifier, + provisioningProfile, + distCert + ) ); } @@ -137,8 +153,10 @@ export default class AppStoreApi { applePlatform: ApplePlatform, profileClass?: ProfileClass ): Promise { - 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( @@ -148,14 +166,16 @@ export default class AppStoreApi { applePlatform: ApplePlatform, profileClass?: ProfileClass ): Promise { - 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 + ) ); } @@ -164,8 +184,9 @@ export default class AppStoreApi { applePlatform: ApplePlatform, profileClass?: ProfileClass ): Promise { - 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( @@ -174,13 +195,15 @@ export default class AppStoreApi { distCertSerialNumber: string, profileType: ProfileType ): Promise { - 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 + ) ); } diff --git a/packages/eas-cli/src/credentials/ios/appstore/__tests__/authenticate-test.ts b/packages/eas-cli/src/credentials/ios/appstore/__tests__/authenticate-test.ts new file mode 100644 index 0000000000..2f74f2e319 --- /dev/null +++ b/packages/eas-cli/src/credentials/ios/appstore/__tests__/authenticate-test.ts @@ -0,0 +1,111 @@ +import { Token } from '@expo/apple-utils'; + +import { + authenticateAsync, + isIndividualAscApiKeyAuthCtx, + withIndividualAscApiKeyProvisioningHint, +} from '../authenticate'; +import { ApiKeyAuthCtx, AppleTeamType, AuthCtx, AuthenticationMode } from '../authenticateTypes'; + +jest.mock('@expo/apple-utils', () => ({ + ...jest.requireActual('@expo/apple-utils'), + Token: jest.fn(() => ({})), +})); + +const apiKeyAuthOptions = { + mode: AuthenticationMode.API_KEY, + teamId: 'team-id', + teamType: AppleTeamType.COMPANY_OR_ORGANIZATION, +}; + +describe(authenticateAsync, () => { + beforeEach(() => { + delete process.env.EXPO_ASC_API_KEY_PATH; + delete process.env.EXPO_ASC_KEY_ID; + delete process.env.EXPO_ASC_ISSUER_ID; + jest.mocked(Token).mockClear(); + }); + + it('authenticates with a team API key', async () => { + const authCtx = (await authenticateAsync({ + ...apiKeyAuthOptions, + ascApiKey: { keyP8: 'p8-content', keyId: 'key-id', issuerId: 'issuer-id' }, + })) as ApiKeyAuthCtx; + + expect(authCtx.ascApiKey.issuerId).toBe('issuer-id'); + expect(Token).toHaveBeenCalledWith( + expect.objectContaining({ keyId: 'key-id', issuerId: 'issuer-id' }) + ); + }); + + it('rejects an individual (issuer-less) API key by default', async () => { + await expect( + authenticateAsync({ + ...apiKeyAuthOptions, + ascApiKey: { keyP8: 'p8-content', keyId: 'key-id' }, + }) + ).rejects.toThrow('individual API key'); + expect(Token).not.toHaveBeenCalled(); + }); + + it('accepts an individual API key when allowIndividualAscApiKey is set', async () => { + const authCtx = (await authenticateAsync({ + ...apiKeyAuthOptions, + allowIndividualAscApiKey: true, + ascApiKey: { keyP8: 'p8-content', keyId: 'key-id' }, + })) as ApiKeyAuthCtx; + + expect(authCtx.ascApiKey.issuerId).toBeUndefined(); + expect(Token).toHaveBeenCalledWith(expect.objectContaining({ keyId: 'key-id' })); + }); +}); + +const individualKeyAuthCtx = { + ascApiKey: { keyP8: 'p8-content', keyId: 'key-id' }, + team: { id: 'team-id' }, +} as AuthCtx; +const teamKeyAuthCtx = { + ascApiKey: { keyP8: 'p8-content', keyId: 'key-id', issuerId: 'issuer-id' }, + team: { id: 'team-id' }, +} as AuthCtx; +const userAuthCtx = { + appleId: 'user@example.com', + team: { id: 'team-id' }, +} as AuthCtx; + +describe(isIndividualAscApiKeyAuthCtx, () => { + it('detects an individual API key auth context', () => { + expect(isIndividualAscApiKeyAuthCtx(individualKeyAuthCtx)).toBe(true); + expect(isIndividualAscApiKeyAuthCtx(teamKeyAuthCtx)).toBe(false); + expect(isIndividualAscApiKeyAuthCtx(userAuthCtx)).toBe(false); + expect(isIndividualAscApiKeyAuthCtx(undefined)).toBe(false); + }); +}); + +describe(withIndividualAscApiKeyProvisioningHint, () => { + it('appends a hint to a 401 NOT_AUTHORIZED error under an individual key', () => { + const error = new Error('Request failed: NOT_AUTHORIZED (401)'); + + const result = withIndividualAscApiKeyProvisioningHint(error, individualKeyAuthCtx); + + expect(result).toBe(error); + expect(error.message).toContain('individual key'); + expect(error.message).toContain('Provisioning endpoints'); + }); + + it('leaves the error unchanged under a team key', () => { + const error = new Error('Request failed: NOT_AUTHORIZED (401)'); + + withIndividualAscApiKeyProvisioningHint(error, teamKeyAuthCtx); + + expect(error.message).toBe('Request failed: NOT_AUTHORIZED (401)'); + }); + + it('leaves unrelated errors unchanged', () => { + const error = new Error('Some other failure'); + + withIndividualAscApiKeyProvisioningHint(error, individualKeyAuthCtx); + + expect(error.message).toBe('Some other failure'); + }); +}); diff --git a/packages/eas-cli/src/credentials/ios/appstore/authenticate.ts b/packages/eas-cli/src/credentials/ios/appstore/authenticate.ts index 913e5f6185..9f22277089 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/authenticate.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/authenticate.ts @@ -37,6 +37,12 @@ export type Options = { teamName?: string; teamType?: AppleTeamType; ascApiKey?: MinimalAscApiKey; + /** + * Allow authenticating with an individual (issuer-less) ASC API key. + * Apple blocks individual keys from Provisioning endpoints, so only flows + * limited to submissions, TestFlight, and metadata may set this. + */ + allowIndividualAscApiKey?: boolean; /** * Can be used to restore the Apple auth state via apple-utils. */ @@ -56,6 +62,33 @@ export function assertUserAuthCtx(authCtx: AuthCtx | undefined): UserAuthCtx { throw new Error('Expected user authentication context (login/password).'); } +export function isIndividualAscApiKeyAuthCtx(authCtx: AuthCtx | undefined): boolean { + return !!authCtx && 'ascApiKey' in authCtx && !!authCtx.ascApiKey && !authCtx.ascApiKey.issuerId; +} + +/** + * Apple responds with 401 NOT_AUTHORIZED (not 403) when an individual API key + * calls a Provisioning endpoint, which looks like a broken key. Append a hint + * so the failure is actionable; keep the original error, as a 401 can also + * mean the key was revoked or expired. + */ +export function withIndividualAscApiKeyProvisioningHint( + error: unknown, + authCtx: AuthCtx | undefined +): unknown { + if ( + error instanceof Error && + isIndividualAscApiKeyAuthCtx(authCtx) && + /NOT_AUTHORIZED|401/.test(error.message) + ) { + error.message += + '\nNote: the App Store Connect API key in use is an individual key (it has no Issuer ID). ' + + 'Apple blocks individual keys from Provisioning endpoints, so this error may not mean the key is invalid. ' + + 'Use a team API key, or provide a distribution certificate and provisioning profile directly.'; + } + return error; +} + export function getRequestContext(authCtx: AuthCtx): RequestContext { assert(authCtx.authState?.context, 'Apple request context must be defined'); return authCtx.authState.context; @@ -163,6 +196,14 @@ export async function authenticateAsync(options: Options = {}): Promise async function authenticateWithApiKeyAsync(options: Options = {}): Promise { // Resolve the user credentials, optimizing for password-less login. const ascApiKey = await resolveAscApiKeyAsync(options.ascApiKey); + if (!ascApiKey.issuerId && !options.allowIndividualAscApiKey) { + throw new Error( + 'The App Store Connect API key has no Issuer ID, so it is an individual API key. ' + + 'Apple blocks individual API keys from managing certificates and provisioning profiles (Provisioning endpoints). ' + + 'Use a team API key (one with an Issuer ID), or provide a distribution certificate and provisioning profile directly. ' + + 'If this is a team key, provide its Issuer ID (e.g. via EXPO_ASC_ISSUER_ID).' + ); + } const team = await resolveAppleTeamAsync(options); const jwtDurationSeconds = 1200; // 20 minutes return { @@ -171,7 +212,9 @@ async function authenticateWithApiKeyAsync(options: Options = {}): Promise { const passedKeyP8 = await getAscKeyP8FromEnvironmentOrOptionsAsync(ascApiKey); const passedKeyId = await getAscKeyIdFromEnvironmentOrOptionsAsync(ascApiKey); - const passedIssuerId = await getAscIssuerIdFromEnvironmentOrOptionsAsync(ascApiKey); + + // A key that is fully specified (key + key ID) without an issuer is an + // individual API key. Do not prompt for the Issuer ID it does not have. + const keyProvidedWithoutIssuer = + (!!ascApiKey?.keyP8 || !!process.env.EXPO_ASC_API_KEY_PATH) && + (!!ascApiKey?.keyId || !!process.env.EXPO_ASC_KEY_ID) && + !ascApiKey?.issuerId && + !process.env.EXPO_ASC_ISSUER_ID; + const passedIssuerId = keyProvidedWithoutIssuer + ? undefined + : await getAscIssuerIdFromEnvironmentOrOptionsAsync(ascApiKey); return { keyP8: passedKeyP8, diff --git a/packages/eas-cli/src/credentials/ios/utils/__tests__/printCredentials-test.ts b/packages/eas-cli/src/credentials/ios/utils/__tests__/printCredentials-test.ts index ad46060ae0..6fa83d0a49 100644 --- a/packages/eas-cli/src/credentials/ios/utils/__tests__/printCredentials-test.ts +++ b/packages/eas-cli/src/credentials/ios/utils/__tests__/printCredentials-test.ts @@ -54,4 +54,41 @@ describe('print credentials', () => { .mock.calls.reduce((acc, mockValue) => acc + mockValue, ''); expect(loggedSoFar).toMatchSnapshot(); }); + + it('prints the key type for an individual (issuer-less) ASC API key', async () => { + jest.mocked(Log.log).mockClear(); + const graphqlClient = instance(mock()); + const app: App = { + account: { + id: 'account-id', + name: 'quinlanj', + viewerUserPermission: { role: Role.Owner }, + }, + projectName: 'test52', + }; + const testIosAppCredentialsData = JSON.parse( + JSON.stringify( + nullthrows( + await IosAppCredentialsQuery.withCommonFieldsByAppIdentifierIdAsync( + graphqlClient, + '@quinlanj/test52', + { + appleAppIdentifierId: 'test-id', + } + ) + ) + ) + ); + testIosAppCredentialsData.appStoreConnectApiKeyForSubmissions.issuerIdentifier = null; + const targets: Target[] = [ + { targetName: 'test52', bundleIdentifier: 'com.quinlanj.test52', entitlements: {} }, + ]; + displayIosCredentials(app, { test52: testIosAppCredentialsData }, targets); + const loggedSoFar = jest + .mocked(Log.log) + .mock.calls.reduce((acc, mockValue) => acc + mockValue, ''); + expect(loggedSoFar).toContain('Key Type'); + expect(loggedSoFar).toContain('Individual (submissions only)'); + expect(loggedSoFar).not.toContain('Issuer ID'); + }); }); diff --git a/packages/eas-cli/src/integrations/asc/ascApiKey.ts b/packages/eas-cli/src/integrations/asc/ascApiKey.ts index f8498cfacd..169e37968e 100644 --- a/packages/eas-cli/src/integrations/asc/ascApiKey.ts +++ b/packages/eas-cli/src/integrations/asc/ascApiKey.ts @@ -2,6 +2,7 @@ import { selectAsync } from '../../prompts'; import { CredentialsContext } from '../../credentials/context'; import { AppStoreApiKeyPurpose, + filterOutIndividualAscApiKeys, formatAscApiKey, provideOrGenerateAscApiKeyAsync, sortAscApiKeysByUpdatedAtDesc, @@ -17,7 +18,9 @@ export async function selectOrCreateAscApiKeyIdAsync({ existingKeys: AppStoreConnectApiKeyFragment[]; ownerAccount: AccountFragment; }): Promise { - const sortedKeys = sortAscApiKeysByUpdatedAtDesc(existingKeys); + // The ASC connection uses Provisioning endpoints, which Apple blocks for + // individual (issuer-less) API keys. + const sortedKeys = sortAscApiKeysByUpdatedAtDesc(filterOutIndividualAscApiKeys(existingKeys)); const createKeyOption = { title: '[Create or upload a new API key]', value: '__create_new_key__', diff --git a/packages/eas-cli/src/metadata/__tests__/auth.test.ts b/packages/eas-cli/src/metadata/__tests__/auth.test.ts index 738fc78241..62ab04c0d1 100644 --- a/packages/eas-cli/src/metadata/__tests__/auth.test.ts +++ b/packages/eas-cli/src/metadata/__tests__/auth.test.ts @@ -130,6 +130,30 @@ describe(getAppStoreAuthAsync, () => { expect(result.app).toBe(mockApp); }); + it('uses individual API key from submit profile when ascApiKeyIssuerId is absent', async () => { + const profile = { + bundleIdentifier: 'com.example.app', + ascApiKeyPath: '/path/to/key.p8', + ascApiKeyId: 'KEY123', + } as any; + const args = createBaseArgs({ profile }); + + const result = await getAppStoreAuthAsync(args); + + expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith( + expect.objectContaining({ + mode: AuthenticationMode.API_KEY, + allowIndividualAscApiKey: true, + ascApiKey: { + keyP8: 'mock-key-p8-content', + keyId: 'KEY123', + issuerId: undefined, + }, + }) + ); + expect(result.app).toBe(mockApp); + }); + it('uses API key from EAS credentials service when available', async () => { (getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue({ id: 'asc-key-id', diff --git a/packages/eas-cli/src/metadata/auth.ts b/packages/eas-cli/src/metadata/auth.ts index 63a55e2349..984546062c 100644 --- a/packages/eas-cli/src/metadata/auth.ts +++ b/packages/eas-cli/src/metadata/auth.ts @@ -68,9 +68,12 @@ async function tryResolveAscApiKeyAsync({ bundleId: string; }): Promise { // 1. Check submit profile for ASC API key fields - if ('ascApiKeyPath' in profile && 'ascApiKeyIssuerId' in profile && 'ascApiKeyId' in profile) { - const { ascApiKeyPath, ascApiKeyIssuerId, ascApiKeyId } = profile; - if (ascApiKeyPath && ascApiKeyIssuerId && ascApiKeyId) { + // ascApiKeyIssuerId is optional: individual API keys have no issuer. + if ('ascApiKeyPath' in profile && 'ascApiKeyId' in profile) { + const { ascApiKeyPath, ascApiKeyId } = profile; + const ascApiKeyIssuerId = + 'ascApiKeyIssuerId' in profile ? profile.ascApiKeyIssuerId : undefined; + if (ascApiKeyPath && ascApiKeyId) { const keyP8 = await fs.promises.readFile(ascApiKeyPath, 'utf-8'); // Also try to get teamId from the profile if available const teamId = 'appleTeamId' in profile ? (profile as any).appleTeamId : undefined; @@ -165,6 +168,9 @@ export async function getAppStoreAuthAsync({ if (resolvedKey || hasAscEnvVars()) { const authOptions: AuthOptions = { mode: AuthenticationMode.API_KEY, + // Metadata sync does not touch Provisioning endpoints, so individual + // (issuer-less) API keys are allowed here. + allowIndividualAscApiKey: true, ...(resolvedKey ? { ascApiKey: resolvedKey.ascApiKey, @@ -186,8 +192,8 @@ export async function getAppStoreAuthAsync({ if (nonInteractive) { throw new Error( 'No App Store Connect API Key found. In non-interactive mode, provide one via:\n' + - ' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' + - ' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId\n' + + ' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID (omit the issuer ID for individual API keys)\n' + + ' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId (omit the issuer ID for individual API keys)\n' + ' - EAS credentials service: run `eas credentials` to set up an API key' ); } diff --git a/packages/eas-cli/src/submit/ios/IosSubmitCommand.ts b/packages/eas-cli/src/submit/ios/IosSubmitCommand.ts index 99481ab54d..6a779a6f83 100644 --- a/packages/eas-cli/src/submit/ios/IosSubmitCommand.ts +++ b/packages/eas-cli/src/submit/ios/IosSubmitCommand.ts @@ -132,7 +132,8 @@ export default class IosSubmitCommand { private resolveAscApiKeySource(): Result { const { ascApiKeyPath, ascApiKeyIssuerId, ascApiKeyId } = this.ctx.profile; - if (ascApiKeyPath && ascApiKeyIssuerId && ascApiKeyId) { + // ascApiKeyIssuerId is optional: individual API keys have no issuer. + if (ascApiKeyPath && ascApiKeyId) { return result({ sourceType: AscApiKeySourceType.path, path: { @@ -145,7 +146,7 @@ export default class IosSubmitCommand { // interpret this to mean the user had some intention of passing in ASC Api key if (ascApiKeyPath || ascApiKeyIssuerId || ascApiKeyId) { - const message = `ascApiKeyPath, ascApiKeyIssuerId and ascApiKeyId must all be defined in eas.json`; + const message = `ascApiKeyPath and ascApiKeyId must both be defined in eas.json (ascApiKeyIssuerId is also required unless the key is an individual API key)`; // in non-interactive mode, we should fail if (this.ctx.nonInteractive) { diff --git a/packages/eas-cli/src/submit/ios/__tests__/IosSubmitCommand-test.ts b/packages/eas-cli/src/submit/ios/__tests__/IosSubmitCommand-test.ts index ae3ce36062..fb5a6531c6 100644 --- a/packages/eas-cli/src/submit/ios/__tests__/IosSubmitCommand-test.ts +++ b/packages/eas-cli/src/submit/ios/__tests__/IosSubmitCommand-test.ts @@ -64,6 +64,7 @@ describe(IosSubmitCommand, () => { const fakeFiles: Record = { '/artifacts/fake.ipa': 'fake ipa', + '/artifacts/asc-key.p8': 'fake asc key p8', }; beforeAll(() => { @@ -214,6 +215,60 @@ describe(IosSubmitCommand, () => { delete process.env.EXPO_APPLE_APP_SPECIFIC_PASSWORD; }); + + it('sends a request to EAS Submit with an individual (issuer-less) ASC API key', async () => { + const projectId = uuidv4(); + const graphqlClient = {} as any as ExpoGraphqlClient; + const analytics = instance(mock()); + jest + .mocked(getArchiveAsync) + .mockImplementation(jest.requireActual('../../ArchiveSource').getArchiveAsync); + + const ctx = await createSubmissionContextAsync({ + platform: Platform.IOS, + projectDir: testProject.projectRoot, + archiveFlags: { + url: 'http://expo.dev/fake.ipa', + }, + profile: { + language: 'en-US', + ascAppId: '12345678', + ascApiKeyPath: '/artifacts/asc-key.p8', + ascApiKeyId: 'KEY123', + }, + nonInteractive: true, + isVerboseFastlaneEnabled: false, + groups: [], + actor: mockJester, + graphqlClient, + analytics, + exp: testProject.appJSON.expo, + projectId, + vcsClient, + }); + const command = new IosSubmitCommand(ctx); + const submitter = await command.runAsync(); + await submitter.submitAsync(); + + expect(SubmissionMutation.createIosSubmissionAsync).toHaveBeenCalledWith(graphqlClient, { + appId: projectId, + archiveSource: { type: SubmissionArchiveSourceType.Url, url: 'http://expo.dev/fake.ipa' }, + config: { + ascAppIdentifier: '12345678', + isVerboseFastlaneEnabled: false, + groups: [], + changelog: undefined, + appleIdUsername: undefined, + ascApiKey: { + keyP8: 'fake asc key p8', + keyIdentifier: 'KEY123', + issuerIdentifier: undefined, + }, + }, + submittedBuildId: undefined, + }); + }); + describe('build selected from EAS', () => { it('sends a request to EAS Submit with profile data matching selected build profile', async () => { const projectId = uuidv4(); diff --git a/packages/eas-cli/src/submit/ios/__tests__/ensureTestFlightSetup-test.ts b/packages/eas-cli/src/submit/ios/__tests__/ensureTestFlightSetup-test.ts index cdf7d87e54..32343b7ff4 100644 --- a/packages/eas-cli/src/submit/ios/__tests__/ensureTestFlightSetup-test.ts +++ b/packages/eas-cli/src/submit/ios/__tests__/ensureTestFlightSetup-test.ts @@ -88,6 +88,7 @@ describe(ensureTestFlightSetupForExistingAppAsync, () => { expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({ mode: AuthenticationMode.API_KEY, + allowIndividualAscApiKey: true, ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' }, teamId: 'team-id', teamName: 'Team', @@ -110,6 +111,27 @@ describe(ensureTestFlightSetupForExistingAppAsync, () => { expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({ mode: AuthenticationMode.API_KEY, + allowIndividualAscApiKey: true, + teamId: 'team-id', + teamType: expect.any(String), + }); + expect(ensureTestFlightGroupExistsAsync).toHaveBeenCalledWith(expect.anything(), { + nonInteractive: true, + }); + }); + + it('sets up TestFlight when environment credentials have no issuer ID (individual key)', async () => { + jest.mocked(hasAscEnvVars).mockReturnValue(true); + process.env.EXPO_ASC_API_KEY_PATH = '/path/to/key.p8'; + process.env.EXPO_ASC_KEY_ID = 'key-id'; + process.env.EXPO_APPLE_TEAM_ID = 'team-id'; + const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true }); + + await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678'); + + expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({ + mode: AuthenticationMode.API_KEY, + allowIndividualAscApiKey: true, teamId: 'team-id', teamType: expect.any(String), }); diff --git a/packages/eas-cli/src/submit/ios/ensureTestFlightSetup.ts b/packages/eas-cli/src/submit/ios/ensureTestFlightSetup.ts index 3234fd94b5..a2f6651fe1 100644 --- a/packages/eas-cli/src/submit/ios/ensureTestFlightSetup.ts +++ b/packages/eas-cli/src/submit/ios/ensureTestFlightSetup.ts @@ -49,17 +49,16 @@ export async function ensureTestFlightSetupForExistingAppAsync( resolveAppleTeamTypeFromEnvironment() ?? AppleTeamType.COMPANY_OR_ORGANIZATION; if (hasAscEnvVars()) { const teamId = process.env.EXPO_APPLE_TEAM_ID; - if ( - !process.env.EXPO_ASC_API_KEY_PATH || - !process.env.EXPO_ASC_KEY_ID || - !process.env.EXPO_ASC_ISSUER_ID || - !teamId - ) { + // EXPO_ASC_ISSUER_ID is optional: individual API keys have no issuer. + if (!process.env.EXPO_ASC_API_KEY_PATH || !process.env.EXPO_ASC_KEY_ID || !teamId) { Log.log('App Store Connect credentials are incomplete, skipping TestFlight setup'); return; } await ctx.credentialsCtx.appStore.ensureAuthenticatedAsync({ mode: AuthenticationMode.API_KEY, + // TestFlight setup does not touch Provisioning endpoints, so + // individual (issuer-less) API keys are allowed here. + allowIndividualAscApiKey: true, teamId, teamType, }); @@ -76,6 +75,7 @@ export async function ensureTestFlightSetupForExistingAppAsync( Log.log('Using App Store Connect API Key from EAS credentials service.'); await ctx.credentialsCtx.appStore.ensureAuthenticatedAsync({ mode: AuthenticationMode.API_KEY, + allowIndividualAscApiKey: true, ascApiKey: resolvedKey.ascApiKey, teamId, teamName: resolvedKey.teamName, diff --git a/packages/eas-cli/src/testflight/app.ts b/packages/eas-cli/src/testflight/app.ts index 444e1b4991..d09bac47f4 100644 --- a/packages/eas-cli/src/testflight/app.ts +++ b/packages/eas-cli/src/testflight/app.ts @@ -76,7 +76,9 @@ async function findAppWithAccountAscApiKeysAsync({ token: new Token({ key: keyP8, keyId: keyIdentifier, - issuerId: issuerIdentifier, + // TODO(ENG-21475): drop the cast once @expo/apple-utils accepts an + // optional issuerId and signs issuer-less tokens with sub: "user". + issuerId: issuerIdentifier as string, duration: ASC_TOKEN_DURATION_SECONDS, }), };