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

- [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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,70 @@
import * as jose from 'jose';

import {
createAscApiTokenAsync,
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isSdkVersionIssueError,
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(
Expand Down
38 changes: 23 additions & 15 deletions packages/build-tools/src/steps/functions/uploadToAsc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down Expand Up @@ -356,6 +342,28 @@ export function createUploadToAscBuildFunction(): BuildFunction {
});
}

export async function createAscApiTokenAsync(ascApiKeyJson: unknown): Promise<string> {
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- ')}`;
}
Expand Down
1 change: 1 addition & 0 deletions packages/eas-build-job/src/submission-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading