From d5e280fb37df61a993762e8fee2e3711221c0aa1 Mon Sep 17 00:00:00 2001 From: sswrk Date: Fri, 21 Aug 2026 18:48:26 +0200 Subject: [PATCH] [build-tools] Support individual ASC API keys in upload_to_asc --- CHANGELOG.md | 2 + .../functions/__tests__/uploadToAsc.test.ts | 59 +++++++++++++++++++ .../src/steps/functions/uploadToAsc.ts | 38 +++++++----- .../eas-build-job/src/submission-config.ts | 1 + 4 files changed, 85 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b30dd53b3..fd0e1597d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ 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)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts b/packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts index 538408b532..fe8e4785d9 100644 --- a/packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts @@ -1,4 +1,7 @@ +import * as jose from 'jose'; + import { + createAscApiTokenAsync, isClosedVersionTrainError, isInvalidBundleIdentifierError, isMissingPurposeStringError, @@ -6,6 +9,62 @@ import { parseMissingUsageDescriptionKeys, } from '../uploadToAsc'; +describe(createAscApiTokenAsync, () => { + let keyPem: string; + + beforeAll(async () => { + const { privateKey } = await jose.generateKeyPair('ES256', { extractable: true }); + keyPem = await jose.exportPKCS8(privateKey); + }); + + it('signs a team key JWT with iss and without sub', async () => { + const token = await createAscApiTokenAsync({ + issuer_id: '6053b7fe-68a8-4acb-89be-165aa6465141', + key_id: 'D383SF739', + key: keyPem, + }); + + const header = jose.decodeProtectedHeader(token); + expect(header).toMatchObject({ alg: 'ES256', kid: 'D383SF739' }); + + const payload = jose.decodeJwt(token); + expect(payload.iss).toBe('6053b7fe-68a8-4acb-89be-165aa6465141'); + expect(payload.sub).toBeUndefined(); + expect(payload.aud).toBe('appstoreconnect-v1'); + }); + + it('signs an individual key JWT with sub "user" and without iss', async () => { + const token = await createAscApiTokenAsync({ + key_id: 'D383SF739', + key: keyPem, + }); + + const header = jose.decodeProtectedHeader(token); + expect(header).toMatchObject({ alg: 'ES256', kid: 'D383SF739' }); + + const payload = jose.decodeJwt(token); + expect(payload.iss).toBeUndefined(); + expect(payload.sub).toBe('user'); + expect(payload.aud).toBe('appstoreconnect-v1'); + }); + + it('treats a null issuer_id as an individual key', async () => { + const token = await createAscApiTokenAsync({ + issuer_id: null, + key_id: 'D383SF739', + key: keyPem, + }); + + const payload = jose.decodeJwt(token); + expect(payload.iss).toBeUndefined(); + expect(payload.sub).toBe('user'); + }); + + it('rejects a key without key_id', async () => { + await expect(createAscApiTokenAsync({ key: keyPem })).rejects.toThrow(); + }); +}); + describe(isClosedVersionTrainError, () => { it('returns true when all errors are closed-version-train codes', () => { expect( diff --git a/packages/build-tools/src/steps/functions/uploadToAsc.ts b/packages/build-tools/src/steps/functions/uploadToAsc.ts index 643be5cb14..85e4810deb 100644 --- a/packages/build-tools/src/steps/functions/uploadToAsc.ts +++ b/packages/build-tools/src/steps/functions/uploadToAsc.ts @@ -88,21 +88,7 @@ export function createUploadToAscBuildFunction(): BuildFunction { } const ascApiKeyJson = await fs.readJson(ascApiKeyPath); - const ascApiKey = z - .object({ - issuer_id: z.string(), - key_id: z.string(), - key: z.string(), - }) - .parse(ascApiKeyJson); - - const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256'); - const token = await new jose.SignJWT({}) - .setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_id }) - .setIssuer(ascApiKey.issuer_id) - .setAudience('appstoreconnect-v1') - .setExpirationTime('20m') - .sign(privateKey); + const token = await createAscApiTokenAsync(ascApiKeyJson); const client = new AscApiClient({ token, logger: stepsCtx.logger }); @@ -356,6 +342,28 @@ export function createUploadToAscBuildFunction(): BuildFunction { }); } +export async function createAscApiTokenAsync(ascApiKeyJson: unknown): Promise { + const ascApiKey = z + .object({ + // Absent (or null) issuer_id means an individual API key. Such keys + // authenticate with `sub: "user"` instead of `iss`. + issuer_id: z.string().nullish(), + key_id: z.string(), + key: z.string(), + }) + .parse(ascApiKeyJson); + + const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256'); + const jwt = new jose.SignJWT(ascApiKey.issuer_id ? {} : { sub: 'user' }) + .setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_id }) + .setAudience('appstoreconnect-v1') + .setExpirationTime('20m'); + if (ascApiKey.issuer_id) { + jwt.setIssuer(ascApiKey.issuer_id); + } + return await jwt.sign(privateKey); +} + function itemizeMessages(messages: { description: string; code: string }[]): string { return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`; } diff --git a/packages/eas-build-job/src/submission-config.ts b/packages/eas-build-job/src/submission-config.ts index ebd4be69d4..f65b1feded 100644 --- a/packages/eas-build-job/src/submission-config.ts +++ b/packages/eas-build-job/src/submission-config.ts @@ -32,6 +32,7 @@ export namespace SubmissionConfig { * issuer_id: "6053b7fe-68a8-4acb-89be-165aa6465141", * key: "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM\n-----END PRIVATE KEY--" * } + * `issuer_id` is omitted for individual API keys (never written as null). */ ascApiJsonKey: z.string(), appleIdUsername: z.never().optional(),