@limrun/ui 0.14.0-rc.5 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -26
- package/dist/components/remote-control.d.ts +36 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1189 -1089
- package/package.json +10 -51
- package/src/components/remote-control.tsx +267 -0
- package/src/index.ts +1 -1
- package/vite.config.ts +0 -9
- package/dist/app-store-relay/index.cjs +0 -9
- package/dist/app-store-relay/index.d.ts +0 -204
- package/dist/app-store-relay/index.js +0 -677
- package/dist/app-store-relay/react.cjs +0 -1
- package/dist/app-store-relay/react.d.ts +0 -15
- package/dist/app-store-relay/react.js +0 -69
- package/dist/apple-credentials/index.cjs +0 -1
- package/dist/apple-credentials/index.d.ts +0 -110
- package/dist/apple-credentials/index.js +0 -291
- package/dist/browser-storage-BnO-Ma-e.mjs +0 -219
- package/dist/browser-storage-DLxIqZk5.js +0 -1
- package/dist/client-C9z_sgOf.js +0 -1
- package/dist/client-CFDB4mX5.mjs +0 -191
- package/dist/core/device-install/apple/client.d.ts +0 -18
- package/dist/core/device-install/apple/crypto.d.ts +0 -20
- package/dist/core/device-install/apple/gsa-srp.d.ts +0 -26
- package/dist/core/device-install/apple/index.d.ts +0 -5
- package/dist/core/device-install/apple/provisioning.d.ts +0 -237
- package/dist/core/device-install/apple/relay.d.ts +0 -53
- package/dist/core/device-install/index.d.ts +0 -4
- package/dist/core/device-install/operations/index.d.ts +0 -6
- package/dist/core/device-install/operations/limbuild-client.d.ts +0 -43
- package/dist/core/device-install/operations/operations.d.ts +0 -47
- package/dist/core/device-install/operations/relay-client.d.ts +0 -26
- package/dist/core/device-install/operations/relay-protocol.d.ts +0 -27
- package/dist/core/device-install/operations/usbmux.d.ts +0 -32
- package/dist/core/device-install/operations/webusb.d.ts +0 -21
- package/dist/core/device-install/storage/browser-storage.d.ts +0 -56
- package/dist/core/device-install/storage/index.d.ts +0 -2
- package/dist/core/device-install/storage/secret-store.d.ts +0 -124
- package/dist/core/device-install/types.d.ts +0 -57
- package/dist/core/errors.d.ts +0 -1
- package/dist/core/play-publish/google.d.ts +0 -13
- package/dist/core/play-publish/publish.d.ts +0 -34
- package/dist/device-build/index.cjs +0 -1
- package/dist/device-build/index.d.ts +0 -4
- package/dist/device-build/index.js +0 -85
- package/dist/device-build/react.cjs +0 -1
- package/dist/device-build/react.d.ts +0 -21
- package/dist/device-build/react.js +0 -68
- package/dist/device-build/signing.d.ts +0 -20
- package/dist/device-install/demo/demo.d.ts +0 -0
- package/dist/device-install/index.cjs +0 -1
- package/dist/device-install/index.d.ts +0 -18
- package/dist/device-install/index.js +0 -594
- package/dist/device-install/react.cjs +0 -1
- package/dist/device-install/react.d.ts +0 -24
- package/dist/device-install/react.js +0 -105
- package/dist/errors-BSkqS7U-.js +0 -1
- package/dist/errors-gy8yQJos.mjs +0 -6
- package/dist/gsa-srp-Bme3JLEi.mjs +0 -266
- package/dist/gsa-srp-CCIfUz7Y.js +0 -1
- package/dist/index-ChhtuQ7e.js +0 -22
- package/dist/index-YDu8K9a4.mjs +0 -11279
- package/dist/limbuild-client-BagK_FWE.mjs +0 -83
- package/dist/limbuild-client-CVDwq54z.js +0 -1
- package/dist/play-publish/index.cjs +0 -1
- package/dist/play-publish/index.d.ts +0 -2
- package/dist/play-publish/index.js +0 -109
- package/dist/play-publish/react.cjs +0 -1
- package/dist/play-publish/react.d.ts +0 -31
- package/dist/play-publish/react.js +0 -91
- package/dist/provisioning-CDPvxd1h.mjs +0 -270
- package/dist/provisioning-CbC4h--7.js +0 -1
- package/src/app-store-relay/index.test.ts +0 -60
- package/src/app-store-relay/index.ts +0 -846
- package/src/app-store-relay/react.ts +0 -131
- package/src/apple-credentials/index.test.ts +0 -180
- package/src/apple-credentials/index.ts +0 -388
- package/src/core/device-install/apple/client.ts +0 -150
- package/src/core/device-install/apple/crypto.ts +0 -205
- package/src/core/device-install/apple/gsa-srp.ts +0 -123
- package/src/core/device-install/apple/index.ts +0 -5
- package/src/core/device-install/apple/provisioning.test.ts +0 -114
- package/src/core/device-install/apple/provisioning.ts +0 -419
- package/src/core/device-install/apple/relay.ts +0 -202
- package/src/core/device-install/index.ts +0 -4
- package/src/core/device-install/operations/index.ts +0 -6
- package/src/core/device-install/operations/limbuild-client.ts +0 -144
- package/src/core/device-install/operations/operations.ts +0 -239
- package/src/core/device-install/operations/relay-client.ts +0 -286
- package/src/core/device-install/operations/relay-protocol.ts +0 -71
- package/src/core/device-install/operations/usbmux.ts +0 -270
- package/src/core/device-install/operations/webusb-dom.d.ts +0 -54
- package/src/core/device-install/operations/webusb.ts +0 -110
- package/src/core/device-install/storage/browser-storage.test.ts +0 -65
- package/src/core/device-install/storage/browser-storage.ts +0 -325
- package/src/core/device-install/storage/index.ts +0 -2
- package/src/core/device-install/storage/secret-store.ts +0 -332
- package/src/core/device-install/types.ts +0 -69
- package/src/core/errors.ts +0 -3
- package/src/core/play-publish/google.ts +0 -113
- package/src/core/play-publish/publish.test.ts +0 -68
- package/src/core/play-publish/publish.ts +0 -89
- package/src/device-build/index.ts +0 -42
- package/src/device-build/react.ts +0 -130
- package/src/device-build/signing.ts +0 -94
- package/src/device-install/README.md +0 -423
- package/src/device-install/demo/demo.css +0 -493
- package/src/device-install/demo/demo.tsx +0 -1061
- package/src/device-install/demo/index.html +0 -12
- package/src/device-install/index.ts +0 -50
- package/src/device-install/react.ts +0 -195
- package/src/play-publish/README.md +0 -66
- package/src/play-publish/index.ts +0 -12
- package/src/play-publish/react.ts +0 -208
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
startBrowserOwnedAppleIDLogin,
|
|
4
|
-
type AppleIDLoginInput,
|
|
5
|
-
type AppleIDLoginResult,
|
|
6
|
-
type AppleRelayResponse,
|
|
7
|
-
} from './index';
|
|
8
|
-
import { errorMessage } from '../core/errors';
|
|
9
|
-
|
|
10
|
-
export type UseAppleIDLoginOptions = Pick<AppleIDLoginInput, 'registryApiUrl' | 'token' | 'organizationId'>;
|
|
11
|
-
|
|
12
|
-
export type AppleIDLoginStatus =
|
|
13
|
-
| 'idle'
|
|
14
|
-
| 'authenticating'
|
|
15
|
-
| 'two-factor-required'
|
|
16
|
-
| 'authenticated'
|
|
17
|
-
| 'error';
|
|
18
|
-
|
|
19
|
-
export type UseAppleIDLoginResult = {
|
|
20
|
-
status: AppleIDLoginStatus;
|
|
21
|
-
session?: AppleIDLoginResult;
|
|
22
|
-
completeResponse?: AppleRelayResponse;
|
|
23
|
-
twoFactorChallengeResponse?: AppleRelayResponse;
|
|
24
|
-
error?: string;
|
|
25
|
-
startLogin: (
|
|
26
|
-
input: Pick<AppleIDLoginInput, 'accountName' | 'password'>,
|
|
27
|
-
) => Promise<AppleIDLoginResult | undefined>;
|
|
28
|
-
submitTwoFactorCode: (code: string) => Promise<AppleRelayResponse | undefined>;
|
|
29
|
-
finalize: () => Promise<AppleRelayResponse | undefined>;
|
|
30
|
-
close: () => Promise<void>;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export function useAppleIDLogin({
|
|
34
|
-
registryApiUrl,
|
|
35
|
-
token,
|
|
36
|
-
organizationId,
|
|
37
|
-
}: UseAppleIDLoginOptions): UseAppleIDLoginResult {
|
|
38
|
-
const [status, setStatus] = useState<AppleIDLoginStatus>('idle');
|
|
39
|
-
const [session, setSession] = useState<AppleIDLoginResult | undefined>();
|
|
40
|
-
const [completeResponse, setCompleteResponse] = useState<AppleRelayResponse | undefined>();
|
|
41
|
-
const [twoFactorChallengeResponse, setTwoFactorChallengeResponse] = useState<
|
|
42
|
-
AppleRelayResponse | undefined
|
|
43
|
-
>();
|
|
44
|
-
const [error, setError] = useState<string | undefined>();
|
|
45
|
-
const sessionRef = useRef<AppleIDLoginResult | undefined>(undefined);
|
|
46
|
-
|
|
47
|
-
const close = useCallback(async () => {
|
|
48
|
-
await sessionRef.current?.close().catch(() => undefined);
|
|
49
|
-
sessionRef.current = undefined;
|
|
50
|
-
setSession(undefined);
|
|
51
|
-
setCompleteResponse(undefined);
|
|
52
|
-
setTwoFactorChallengeResponse(undefined);
|
|
53
|
-
setError(undefined);
|
|
54
|
-
setStatus('idle');
|
|
55
|
-
}, []);
|
|
56
|
-
|
|
57
|
-
useEffect(() => {
|
|
58
|
-
return () => {
|
|
59
|
-
void sessionRef.current?.close();
|
|
60
|
-
sessionRef.current = undefined;
|
|
61
|
-
};
|
|
62
|
-
}, []);
|
|
63
|
-
|
|
64
|
-
const startLogin = useCallback(
|
|
65
|
-
async ({ accountName, password }: Pick<AppleIDLoginInput, 'accountName' | 'password'>) => {
|
|
66
|
-
setStatus('authenticating');
|
|
67
|
-
setError(undefined);
|
|
68
|
-
await sessionRef.current?.close().catch(() => undefined);
|
|
69
|
-
try {
|
|
70
|
-
const next = await startBrowserOwnedAppleIDLogin({
|
|
71
|
-
registryApiUrl,
|
|
72
|
-
token,
|
|
73
|
-
organizationId,
|
|
74
|
-
accountName,
|
|
75
|
-
password,
|
|
76
|
-
});
|
|
77
|
-
sessionRef.current = next;
|
|
78
|
-
setSession(next);
|
|
79
|
-
setCompleteResponse(next.completeResponse);
|
|
80
|
-
setTwoFactorChallengeResponse(next.twoFactorChallengeResponse);
|
|
81
|
-
setStatus(next.requiresTwoFactor ? 'two-factor-required' : 'authenticated');
|
|
82
|
-
return next;
|
|
83
|
-
} catch (caught) {
|
|
84
|
-
setSession(undefined);
|
|
85
|
-
sessionRef.current = undefined;
|
|
86
|
-
setError(errorMessage(caught));
|
|
87
|
-
setStatus('error');
|
|
88
|
-
return undefined;
|
|
89
|
-
}
|
|
90
|
-
},
|
|
91
|
-
[registryApiUrl, organizationId, token],
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
const submitTwoFactorCode = useCallback(async (code: string) => {
|
|
95
|
-
const current = sessionRef.current;
|
|
96
|
-
if (!current) {
|
|
97
|
-
throw new Error('Start Apple ID login before submitting a two-factor code.');
|
|
98
|
-
}
|
|
99
|
-
setStatus('authenticating');
|
|
100
|
-
setError(undefined);
|
|
101
|
-
try {
|
|
102
|
-
const response = await current.finishTwoFactor(code);
|
|
103
|
-
setStatus('authenticated');
|
|
104
|
-
return response;
|
|
105
|
-
} catch (caught) {
|
|
106
|
-
setError(errorMessage(caught));
|
|
107
|
-
setStatus('error');
|
|
108
|
-
return undefined;
|
|
109
|
-
}
|
|
110
|
-
}, []);
|
|
111
|
-
|
|
112
|
-
const finalize = useCallback(async () => {
|
|
113
|
-
const current = sessionRef.current;
|
|
114
|
-
if (!current) {
|
|
115
|
-
throw new Error('Start Apple ID login before finalizing the Apple session.');
|
|
116
|
-
}
|
|
117
|
-
return current.finalize();
|
|
118
|
-
}, []);
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
status,
|
|
122
|
-
session,
|
|
123
|
-
completeResponse,
|
|
124
|
-
twoFactorChallengeResponse,
|
|
125
|
-
error,
|
|
126
|
-
startLogin,
|
|
127
|
-
submitTwoFactorCode,
|
|
128
|
-
finalize,
|
|
129
|
-
close,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
// @vitest-environment node
|
|
2
|
-
|
|
3
|
-
import forge from 'node-forge';
|
|
4
|
-
import { describe, expect, test } from 'vitest';
|
|
5
|
-
import type { AppleRelayWebSocketClient } from '../core/device-install/apple';
|
|
6
|
-
import {
|
|
7
|
-
APPLE_CERTIFICATE_SECRET_TYPE,
|
|
8
|
-
appleCertificateSecretName,
|
|
9
|
-
ensureAppleCertificateSecret,
|
|
10
|
-
type SigningSecret,
|
|
11
|
-
type SigningSecretMetadata,
|
|
12
|
-
type SigningSecretStore,
|
|
13
|
-
type SigningSecretType,
|
|
14
|
-
} from './index';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A self-signed certificate is enough for exportAppleCertificateP12: the
|
|
18
|
-
* p12 bundling never verifies that the key matches the certificate.
|
|
19
|
-
*/
|
|
20
|
-
function selfSignedCertificateBase64() {
|
|
21
|
-
const keys = forge.pki.rsa.generateKeyPair(1024);
|
|
22
|
-
const cert = forge.pki.createCertificate();
|
|
23
|
-
cert.publicKey = keys.publicKey;
|
|
24
|
-
cert.serialNumber = '01';
|
|
25
|
-
cert.validity.notBefore = new Date();
|
|
26
|
-
cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
27
|
-
const attrs = [{ name: 'commonName', value: 'Apple Development Test' }];
|
|
28
|
-
cert.setSubject(attrs);
|
|
29
|
-
cert.setIssuer(attrs);
|
|
30
|
-
cert.sign(keys.privateKey);
|
|
31
|
-
return forge.util.encode64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes());
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function memorySecretStore(options: { failPuts?: () => boolean } = {}) {
|
|
35
|
-
const entries = new Map<string, SigningSecret>();
|
|
36
|
-
const store: SigningSecretStore = {
|
|
37
|
-
async put(type: SigningSecretType, name: string, data: Record<string, string>) {
|
|
38
|
-
if (options.failPuts?.()) {
|
|
39
|
-
throw new Error('secret store unavailable');
|
|
40
|
-
}
|
|
41
|
-
const secret: SigningSecret = { type, name, data, createdAt: new Date().toISOString() };
|
|
42
|
-
entries.set(`${type}:${name}`, secret);
|
|
43
|
-
return secret;
|
|
44
|
-
},
|
|
45
|
-
async get(type: SigningSecretType, name: string) {
|
|
46
|
-
return entries.get(`${type}:${name}`);
|
|
47
|
-
},
|
|
48
|
-
async list() {
|
|
49
|
-
return [...entries.values()].map(({ data: _data, ...metadata }): SigningSecretMetadata => metadata);
|
|
50
|
-
},
|
|
51
|
-
async delete(type: SigningSecretType, name: string) {
|
|
52
|
-
entries.delete(`${type}:${name}`);
|
|
53
|
-
},
|
|
54
|
-
};
|
|
55
|
-
return { store, entries };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
type PortalState = { mintedCertificateIds: string[]; certificateBase64: string };
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Fakes the relay's provisioning proxy: minting appends to
|
|
62
|
-
* mintedCertificateIds, listing reflects what has been minted so far.
|
|
63
|
-
*/
|
|
64
|
-
function fakePortalRelay(state: PortalState): AppleRelayWebSocketClient {
|
|
65
|
-
return {
|
|
66
|
-
async request(_type: string, payload: unknown) {
|
|
67
|
-
const request = payload as { path: string };
|
|
68
|
-
if (request.path.includes('listCertRequests')) {
|
|
69
|
-
return portalOK({
|
|
70
|
-
certRequests: state.mintedCertificateIds.map((certificateId) => ({ certificateId })),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
if (request.path.includes('submitCertificateRequest')) {
|
|
74
|
-
const certificateId = `CERT${state.mintedCertificateIds.length + 1}`;
|
|
75
|
-
state.mintedCertificateIds.push(certificateId);
|
|
76
|
-
return portalOK({
|
|
77
|
-
certRequest: {
|
|
78
|
-
certificateId,
|
|
79
|
-
serialNum: '01',
|
|
80
|
-
expirationDateString: '2027-01-01',
|
|
81
|
-
},
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
if (request.path.includes('downloadCertificateContent')) {
|
|
85
|
-
return { status: 200, statusText: 'OK', rawBodyBase64: state.certificateBase64 };
|
|
86
|
-
}
|
|
87
|
-
throw new Error(`unexpected portal path: ${request.path}`);
|
|
88
|
-
},
|
|
89
|
-
} as unknown as AppleRelayWebSocketClient;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function portalOK(body: Record<string, unknown>) {
|
|
93
|
-
return { status: 200, statusText: 'OK', body: { resultCode: 0, ...body } };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
describe('ensureAppleCertificateSecret', () => {
|
|
97
|
-
const teamId = 'TEAM1';
|
|
98
|
-
const secretName = appleCertificateSecretName(teamId, 'DEVELOPMENT');
|
|
99
|
-
|
|
100
|
-
test('surfaces an actionable error when the store write fails after minting', async () => {
|
|
101
|
-
const state: PortalState = {
|
|
102
|
-
mintedCertificateIds: [],
|
|
103
|
-
certificateBase64: selfSignedCertificateBase64(),
|
|
104
|
-
};
|
|
105
|
-
const broken = memorySecretStore({ failPuts: () => true });
|
|
106
|
-
|
|
107
|
-
await expect(
|
|
108
|
-
ensureAppleCertificateSecret({
|
|
109
|
-
relay: fakePortalRelay(state),
|
|
110
|
-
teamId,
|
|
111
|
-
secretStore: broken.store,
|
|
112
|
-
}),
|
|
113
|
-
).rejects.toThrow(/revoke certificate CERT1/);
|
|
114
|
-
expect(state.mintedCertificateIds).toEqual(['CERT1']);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test('mints and stores the certificate with its type in the secret name', async () => {
|
|
118
|
-
const state: PortalState = {
|
|
119
|
-
mintedCertificateIds: [],
|
|
120
|
-
certificateBase64: selfSignedCertificateBase64(),
|
|
121
|
-
};
|
|
122
|
-
const org = memorySecretStore();
|
|
123
|
-
|
|
124
|
-
const result = await ensureAppleCertificateSecret({
|
|
125
|
-
relay: fakePortalRelay(state),
|
|
126
|
-
teamId,
|
|
127
|
-
secretStore: org.store,
|
|
128
|
-
});
|
|
129
|
-
expect(result.created).toBe(true);
|
|
130
|
-
expect(result.certificateId).toBe('CERT1');
|
|
131
|
-
const stored = await org.store.get(APPLE_CERTIFICATE_SECRET_TYPE, secretName);
|
|
132
|
-
expect(stored?.data.certificateID).toBe('CERT1');
|
|
133
|
-
expect(stored?.data.certificateType).toBe('DEVELOPMENT');
|
|
134
|
-
expect(stored?.data.certificateP12Base64).toBeTruthy();
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test('mints distribution certificates under their own secret name', async () => {
|
|
138
|
-
const state: PortalState = {
|
|
139
|
-
mintedCertificateIds: [],
|
|
140
|
-
certificateBase64: selfSignedCertificateBase64(),
|
|
141
|
-
};
|
|
142
|
-
const org = memorySecretStore();
|
|
143
|
-
|
|
144
|
-
const result = await ensureAppleCertificateSecret({
|
|
145
|
-
relay: fakePortalRelay(state),
|
|
146
|
-
teamId,
|
|
147
|
-
secretStore: org.store,
|
|
148
|
-
certificateKind: 'distribution',
|
|
149
|
-
});
|
|
150
|
-
expect(result.created).toBe(true);
|
|
151
|
-
const stored = await org.store.get(
|
|
152
|
-
APPLE_CERTIFICATE_SECRET_TYPE,
|
|
153
|
-
appleCertificateSecretName(teamId, 'DISTRIBUTION'),
|
|
154
|
-
);
|
|
155
|
-
expect(stored?.data.certificateType).toBe('DISTRIBUTION');
|
|
156
|
-
expect(stored?.data.certificateP12Base64).toBeTruthy();
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test('reuses the stored certificate when it is still on the team', async () => {
|
|
160
|
-
const state: PortalState = {
|
|
161
|
-
mintedCertificateIds: ['CERT1'],
|
|
162
|
-
certificateBase64: selfSignedCertificateBase64(),
|
|
163
|
-
};
|
|
164
|
-
const org = memorySecretStore();
|
|
165
|
-
await org.store.put(APPLE_CERTIFICATE_SECRET_TYPE, secretName, {
|
|
166
|
-
certificateP12Base64: 'cDEy',
|
|
167
|
-
certificateID: 'CERT1',
|
|
168
|
-
teamID: teamId,
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
const result = await ensureAppleCertificateSecret({
|
|
172
|
-
relay: fakePortalRelay(state),
|
|
173
|
-
teamId,
|
|
174
|
-
secretStore: org.store,
|
|
175
|
-
});
|
|
176
|
-
expect(result.created).toBe(false);
|
|
177
|
-
expect(result.certificateId).toBe('CERT1');
|
|
178
|
-
expect(state.mintedCertificateIds).toEqual(['CERT1']);
|
|
179
|
-
});
|
|
180
|
-
});
|
|
@@ -1,388 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Headless helpers that turn an authenticated Apple relay session into
|
|
3
|
-
* signing credentials persisted in a SigningSecretStore. The Apple relay
|
|
4
|
-
* only proxies: certificate and profile bytes always land in the browser
|
|
5
|
-
* first, and the browser writes them into the configured store (Limrun's
|
|
6
|
-
* org secret store by default, or a customer-provided one).
|
|
7
|
-
*/
|
|
8
|
-
import {
|
|
9
|
-
createAppleCertificate,
|
|
10
|
-
createAppStoreConnectApiKey,
|
|
11
|
-
downloadAppleCertificate,
|
|
12
|
-
downloadAppleProfile,
|
|
13
|
-
downloadAppStoreConnectApiKeyPrivateKey,
|
|
14
|
-
exportAppleCertificateP12,
|
|
15
|
-
generateAppleSigningKeyAndCSR,
|
|
16
|
-
listAppleCertificates,
|
|
17
|
-
listAppleProfiles,
|
|
18
|
-
listAppStoreConnectApiKeys,
|
|
19
|
-
switchAppStoreConnectProvider,
|
|
20
|
-
type AppleCertificateKind,
|
|
21
|
-
type AppleProfileKind,
|
|
22
|
-
type AppStoreConnectApiKeyRole,
|
|
23
|
-
} from '../app-store-relay';
|
|
24
|
-
import type { AppleRelayWebSocketClient } from '../core/device-install/apple';
|
|
25
|
-
import {
|
|
26
|
-
normalizeCertificateSerial,
|
|
27
|
-
parseProvisioningProfileBase64,
|
|
28
|
-
} from '../core/device-install/storage/browser-storage';
|
|
29
|
-
import {
|
|
30
|
-
APP_STORE_CONNECT_API_KEY_SECRET_TYPE,
|
|
31
|
-
APPLE_CERTIFICATE_SECRET_TYPE,
|
|
32
|
-
putAppleCertificateSecret,
|
|
33
|
-
putAppleProvisioningProfileSecret,
|
|
34
|
-
putAppStoreConnectApiKeySecret,
|
|
35
|
-
type AppleCertificateSecretData,
|
|
36
|
-
type AppleCertificateType,
|
|
37
|
-
type AppStoreConnectApiKeySecretData,
|
|
38
|
-
type SigningSecret,
|
|
39
|
-
type SigningSecretStore,
|
|
40
|
-
} from '../core/device-install/storage/secret-store';
|
|
41
|
-
|
|
42
|
-
export {
|
|
43
|
-
APP_STORE_CONNECT_API_KEY_SECRET_TYPE,
|
|
44
|
-
APPLE_CERTIFICATE_SECRET_TYPE,
|
|
45
|
-
APPLE_PROVISIONING_PROFILE_SECRET_TYPE,
|
|
46
|
-
createBrowserSecretStore,
|
|
47
|
-
createLimrunSecretStore,
|
|
48
|
-
putAppleCertificateSecret,
|
|
49
|
-
putAppleProvisioningProfileSecret,
|
|
50
|
-
putAppStoreConnectApiKeySecret,
|
|
51
|
-
type AppleCertificateSecretData,
|
|
52
|
-
type AppleCertificateType,
|
|
53
|
-
type AppleProvisioningProfileSecretData,
|
|
54
|
-
type AppStoreConnectApiKeySecretData,
|
|
55
|
-
type LimrunSecretStoreOptions,
|
|
56
|
-
type SigningSecret,
|
|
57
|
-
type SigningSecretData,
|
|
58
|
-
type SigningSecretMetadata,
|
|
59
|
-
type SigningSecretStore,
|
|
60
|
-
type SigningSecretType,
|
|
61
|
-
} from '../core/device-install/storage/secret-store';
|
|
62
|
-
export { normalizeCertificateSerial } from '../core/device-install/storage/browser-storage';
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Conventional secret name of a team's certificate bundle. One bundle per
|
|
66
|
-
* team and Apple certificate type; the type is in the name so development
|
|
67
|
-
* and distribution material never collide.
|
|
68
|
-
*/
|
|
69
|
-
export function appleCertificateSecretName(teamId: string, certificateType: AppleCertificateType) {
|
|
70
|
-
return `${teamId}/${certificateType}`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export type EnsureAppleCertificateInput = {
|
|
74
|
-
relay: AppleRelayWebSocketClient;
|
|
75
|
-
teamId: string;
|
|
76
|
-
secretStore: SigningSecretStore;
|
|
77
|
-
/**
|
|
78
|
-
* Which portal certificate kind to ensure. Ad-hoc and App Store signing
|
|
79
|
-
* both use the distribution certificate. Defaults to development.
|
|
80
|
-
*/
|
|
81
|
-
certificateKind?: AppleCertificateKind;
|
|
82
|
-
/** Common name used when minting a new certificate. */
|
|
83
|
-
commonName?: string;
|
|
84
|
-
log?: (message: string, detail?: string) => void;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export type EnsureAppleCertificateResult = {
|
|
88
|
-
secret: SigningSecret;
|
|
89
|
-
certificateId: string;
|
|
90
|
-
/** True when a new certificate was minted instead of reusing the stored one. */
|
|
91
|
-
created: boolean;
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Returns a usable certificate secret of the requested kind for the team,
|
|
96
|
-
* reusing the stored one when its certificate is still on the team and
|
|
97
|
-
* minting a new one otherwise. Apple caps certificates per kind (2 for
|
|
98
|
-
* development, 3 for distribution) and never returns private keys, so
|
|
99
|
-
* reuse of the stored p12 is strongly preferred.
|
|
100
|
-
*
|
|
101
|
-
* Durability of the stored material is the secret store's concern:
|
|
102
|
-
* implementors who want retries or fallbacks build them into their
|
|
103
|
-
* SigningSecretStore.
|
|
104
|
-
*/
|
|
105
|
-
export async function ensureAppleCertificateSecret({
|
|
106
|
-
relay,
|
|
107
|
-
teamId,
|
|
108
|
-
secretStore,
|
|
109
|
-
certificateKind = 'development',
|
|
110
|
-
commonName,
|
|
111
|
-
log = () => {},
|
|
112
|
-
}: EnsureAppleCertificateInput): Promise<EnsureAppleCertificateResult> {
|
|
113
|
-
// The portal kinds map onto Apple's App Store Connect certificate type
|
|
114
|
-
// enum, which is what consumers of the stored secret filter on.
|
|
115
|
-
const certificateType: AppleCertificateType =
|
|
116
|
-
certificateKind === 'distribution' ? 'DISTRIBUTION' : 'DEVELOPMENT';
|
|
117
|
-
const secretName = appleCertificateSecretName(teamId, certificateType);
|
|
118
|
-
const current = await listAppleCertificates({ relay, teamId, certificateKind });
|
|
119
|
-
const findOnTeam = (certificateId: string | undefined) =>
|
|
120
|
-
certificateId === undefined ? undefined : (
|
|
121
|
-
current.find(
|
|
122
|
-
(item) =>
|
|
123
|
-
stringField(item, 'certificateId') === certificateId ||
|
|
124
|
-
stringField(item, 'certRequestId') === certificateId,
|
|
125
|
-
)
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
const stored = await secretStore.get(APPLE_CERTIFICATE_SECRET_TYPE, secretName);
|
|
129
|
-
const storedCertificateId = stringField(stored?.data, 'certificateID');
|
|
130
|
-
if (stored && storedCertificateId && stored.data.certificateP12Base64) {
|
|
131
|
-
// The stored value may be a certRequestId; resolve the canonical
|
|
132
|
-
// certificateId the profile API expects. If the cert is no longer on
|
|
133
|
-
// the team (revoked), fall through and mint a new one.
|
|
134
|
-
const matched = findOnTeam(storedCertificateId);
|
|
135
|
-
if (matched) {
|
|
136
|
-
const certificateId = stringField(matched, 'certificateId') ?? storedCertificateId;
|
|
137
|
-
log('Reusing stored Apple certificate', certificateId);
|
|
138
|
-
return { secret: stored, certificateId, created: false };
|
|
139
|
-
}
|
|
140
|
-
log('Stored certificate is no longer on the team', 'Minting a new one.');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// The private key is generated and kept in this browser; Apple only ever
|
|
144
|
-
// sees the CSR. Without this key the downloaded cert cannot sign anything.
|
|
145
|
-
const key = await generateAppleSigningKeyAndCSR({ commonName: commonName ?? `Limrun ${teamId}` });
|
|
146
|
-
const certificate = await createAppleCertificate({
|
|
147
|
-
relay,
|
|
148
|
-
teamId,
|
|
149
|
-
certificateKind,
|
|
150
|
-
csrPEM: key.csrPEM,
|
|
151
|
-
});
|
|
152
|
-
const certificateId =
|
|
153
|
-
stringField(certificate, 'certificateId') ?? stringField(certificate, 'certRequestId');
|
|
154
|
-
if (!certificateId) {
|
|
155
|
-
throw new Error('Apple certificate creation did not return a certificate ID.');
|
|
156
|
-
}
|
|
157
|
-
const downloaded = await downloadAppleCertificate({
|
|
158
|
-
relay,
|
|
159
|
-
teamId,
|
|
160
|
-
certificateKind,
|
|
161
|
-
certificateId,
|
|
162
|
-
});
|
|
163
|
-
if (!downloaded.rawBodyBase64) {
|
|
164
|
-
throw new Error('Apple certificate download returned no bytes.');
|
|
165
|
-
}
|
|
166
|
-
const data: AppleCertificateSecretData = {
|
|
167
|
-
certificateP12Base64: exportAppleCertificateP12({
|
|
168
|
-
privateKeyPKCS8Base64: key.privateKeyPKCS8Base64,
|
|
169
|
-
certificateBase64: downloaded.rawBodyBase64,
|
|
170
|
-
password: '',
|
|
171
|
-
friendlyName: `Apple ${certificateKind === 'distribution' ? 'Distribution' : 'Development'} ${teamId}`,
|
|
172
|
-
}),
|
|
173
|
-
certificateType,
|
|
174
|
-
teamID: teamId,
|
|
175
|
-
certificateID: certificateId,
|
|
176
|
-
// Normalized so it equals the serials profiles reference.
|
|
177
|
-
serialNumber: normalizeCertificateSerial(stringField(certificate, 'serialNum')),
|
|
178
|
-
expirationDate: stringField(certificate, 'expirationDateString'),
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
let secret: SigningSecret;
|
|
182
|
-
try {
|
|
183
|
-
secret = await putAppleCertificateSecret(secretStore, secretName, data);
|
|
184
|
-
} catch (error) {
|
|
185
|
-
// The key existed only in this browser's memory; once this throw
|
|
186
|
-
// unwinds it is gone, and the portal certificate is unusable.
|
|
187
|
-
throw new Error(
|
|
188
|
-
`Certificate ${certificateId} was created on the Apple Developer portal but saving its private ` +
|
|
189
|
-
`key to the secret store failed: ${errorText(error)}. The key cannot be recovered; revoke ` +
|
|
190
|
-
`certificate ${certificateId} at developer.apple.com before retrying, or the retry will mint ` +
|
|
191
|
-
`another certificate against Apple's limit.`,
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
log('Apple certificate stored', certificateId);
|
|
195
|
-
return { secret, certificateId, created: true };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function errorText(error: unknown) {
|
|
199
|
-
return error instanceof Error ? error.message : String(error);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export type SaveAppleProfileInput = {
|
|
203
|
-
relay: AppleRelayWebSocketClient;
|
|
204
|
-
teamId: string;
|
|
205
|
-
profileId: string;
|
|
206
|
-
secretStore: SigningSecretStore;
|
|
207
|
-
log?: (message: string, detail?: string) => void;
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Downloads the provisioning profile and stores it as an
|
|
212
|
-
* appleProvisioningProfile secret named `${teamId}/${uuid}`. The UUID is
|
|
213
|
-
* unique per profile, so a team can hold many profiles for the same
|
|
214
|
-
* bundle ID and certificate set; the reference fields (certificate
|
|
215
|
-
* serials, bundle IDs, device IDs parsed out of the profile) are what
|
|
216
|
-
* consumers filter on.
|
|
217
|
-
*/
|
|
218
|
-
export async function saveAppleProfileSecret({
|
|
219
|
-
relay,
|
|
220
|
-
teamId,
|
|
221
|
-
profileId,
|
|
222
|
-
secretStore,
|
|
223
|
-
log = () => {},
|
|
224
|
-
}: SaveAppleProfileInput): Promise<SigningSecret> {
|
|
225
|
-
const downloaded = await downloadAppleProfile({ relay, teamId, profileId });
|
|
226
|
-
const profileBase64 = downloaded.rawBodyBase64;
|
|
227
|
-
if (!profileBase64) {
|
|
228
|
-
throw new Error('Apple provisioning profile download returned no bytes.');
|
|
229
|
-
}
|
|
230
|
-
const info = parseProvisioningProfileBase64(profileBase64);
|
|
231
|
-
const name = `${teamId}/${info.uuid ?? profileId}`;
|
|
232
|
-
const secret = await putAppleProvisioningProfileSecret(secretStore, name, {
|
|
233
|
-
provisioningProfileBase64: profileBase64,
|
|
234
|
-
certificateSerialNumbers: info.certificateSerialNumbers.join(','),
|
|
235
|
-
bundleIDs: info.bundleID,
|
|
236
|
-
deviceIDs: info.provisionedDevices.join(','),
|
|
237
|
-
teamID: info.teamID ?? teamId,
|
|
238
|
-
profileName: info.name,
|
|
239
|
-
uuid: info.uuid,
|
|
240
|
-
expirationDate: info.expirationDate,
|
|
241
|
-
});
|
|
242
|
-
log('Apple provisioning profile stored', name);
|
|
243
|
-
return secret;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Conventional secret name of a team's App Store Connect API key: one
|
|
248
|
-
* shared key per team.
|
|
249
|
-
*/
|
|
250
|
-
export function appStoreConnectApiKeySecretName(teamId: string) {
|
|
251
|
-
return `${teamId}/APP_STORE_CONNECT_API_KEY`;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export type EnsureAppStoreConnectApiKeyInput = {
|
|
255
|
-
relay: AppleRelayWebSocketClient;
|
|
256
|
-
teamId: string;
|
|
257
|
-
/**
|
|
258
|
-
* Numeric provider ID of the team from the Apple team list. When given,
|
|
259
|
-
* the App Store Connect session is switched to this provider first;
|
|
260
|
-
* required for accounts that belong to multiple teams.
|
|
261
|
-
*/
|
|
262
|
-
providerId?: string | number;
|
|
263
|
-
secretStore: SigningSecretStore;
|
|
264
|
-
/** Display name for a newly minted key. Required; there is no default. */
|
|
265
|
-
nickname: string;
|
|
266
|
-
/** Roles of a newly minted key. Defaults to APP_MANAGER. */
|
|
267
|
-
roles?: AppStoreConnectApiKeyRole[];
|
|
268
|
-
log?: (message: string, detail?: string) => void;
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
export type EnsureAppStoreConnectApiKeyResult = {
|
|
272
|
-
secret: SigningSecret;
|
|
273
|
-
keyId: string;
|
|
274
|
-
/** True when a new key was minted instead of reusing the stored one. */
|
|
275
|
-
created: boolean;
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Returns a usable App Store Connect API key secret for the team, reusing
|
|
280
|
-
* the stored one when its key is still active and minting a new one
|
|
281
|
-
* otherwise. Apple serves a key's private half exactly once (at creation
|
|
282
|
-
* time through the sparse fieldset download), so reuse of the stored .p8
|
|
283
|
-
* is strongly preferred.
|
|
284
|
-
*
|
|
285
|
-
* The session user must be a team Admin to list or create keys.
|
|
286
|
-
*/
|
|
287
|
-
export async function ensureAppStoreConnectApiKeySecret({
|
|
288
|
-
relay,
|
|
289
|
-
teamId,
|
|
290
|
-
providerId,
|
|
291
|
-
secretStore,
|
|
292
|
-
nickname,
|
|
293
|
-
roles,
|
|
294
|
-
log = () => {},
|
|
295
|
-
}: EnsureAppStoreConnectApiKeyInput): Promise<EnsureAppStoreConnectApiKeyResult> {
|
|
296
|
-
if (!nickname) {
|
|
297
|
-
throw new Error('A nickname is required to ensure an App Store Connect API key.');
|
|
298
|
-
}
|
|
299
|
-
if (providerId !== undefined) {
|
|
300
|
-
await switchAppStoreConnectProvider({ relay, providerId });
|
|
301
|
-
}
|
|
302
|
-
const secretName = appStoreConnectApiKeySecretName(teamId);
|
|
303
|
-
// Listing also primes the session's CSRF context, which Apple requires
|
|
304
|
-
// on the creation POST below.
|
|
305
|
-
const current = await listAppStoreConnectApiKeys({ relay });
|
|
306
|
-
|
|
307
|
-
const stored = await secretStore.get(APP_STORE_CONNECT_API_KEY_SECRET_TYPE, secretName);
|
|
308
|
-
const storedKeyId = stringField(stored?.data, 'keyId');
|
|
309
|
-
if (stored && storedKeyId && stored.data.privateKeyP8Base64) {
|
|
310
|
-
const matched = current.find((item) => item.id === storedKeyId && !keyRevoked(item.attributes));
|
|
311
|
-
if (matched) {
|
|
312
|
-
log('Reusing stored App Store Connect API key', storedKeyId);
|
|
313
|
-
return { secret: stored, keyId: storedKeyId, created: false };
|
|
314
|
-
}
|
|
315
|
-
log('Stored App Store Connect API key is no longer active', 'Minting a new one.');
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const created = await createAppStoreConnectApiKey({ relay, nickname, roles });
|
|
319
|
-
const keyId = created.id!;
|
|
320
|
-
const downloaded = await downloadAppStoreConnectApiKeyPrivateKey({ relay, keyId });
|
|
321
|
-
const data: AppStoreConnectApiKeySecretData = {
|
|
322
|
-
privateKeyP8Base64: base64FromText(downloaded.privateKeyPem),
|
|
323
|
-
keyId,
|
|
324
|
-
issuerId: downloaded.issuerId,
|
|
325
|
-
nickname,
|
|
326
|
-
teamID: teamId,
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
let secret: SigningSecret;
|
|
330
|
-
try {
|
|
331
|
-
secret = await putAppStoreConnectApiKeySecret(secretStore, secretName, data);
|
|
332
|
-
} catch (error) {
|
|
333
|
-
// Apple never serves the private key again; once this throw unwinds
|
|
334
|
-
// the downloaded copy is gone and the portal key is unusable.
|
|
335
|
-
throw new Error(
|
|
336
|
-
`App Store Connect API key ${keyId} was created but saving its private key to the secret ` +
|
|
337
|
-
`store failed: ${errorText(error)}. The key cannot be downloaded again; revoke key ` +
|
|
338
|
-
`${keyId} in App Store Connect before retrying, or the retry will mint another key.`,
|
|
339
|
-
);
|
|
340
|
-
}
|
|
341
|
-
log('App Store Connect API key stored', keyId);
|
|
342
|
-
return { secret, keyId, created: true };
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function keyRevoked(attributes: Record<string, unknown> | undefined) {
|
|
346
|
-
if (!attributes) return false;
|
|
347
|
-
if (attributes.isActive === false) return true;
|
|
348
|
-
const revokingDate = attributes.revokingDate ?? attributes.revocationDate;
|
|
349
|
-
return typeof revokingDate === 'string' && revokingDate !== '';
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function base64FromText(text: string) {
|
|
353
|
-
const bytes = new TextEncoder().encode(text);
|
|
354
|
-
let binary = '';
|
|
355
|
-
for (const byte of bytes) {
|
|
356
|
-
binary += String.fromCharCode(byte);
|
|
357
|
-
}
|
|
358
|
-
return btoa(binary);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
export type ListTeamProfilesInput = {
|
|
362
|
-
relay: AppleRelayWebSocketClient;
|
|
363
|
-
teamId: string;
|
|
364
|
-
/**
|
|
365
|
-
* Which portal profile listing to use. The development listing is
|
|
366
|
-
* unfiltered on the portal side and returns profiles of every
|
|
367
|
-
* distribution method; adhoc narrows to ad-hoc profiles. Defaults to
|
|
368
|
-
* development.
|
|
369
|
-
*/
|
|
370
|
-
profileKind?: AppleProfileKind;
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
/** Lists the team's provisioning profiles from the portal. */
|
|
374
|
-
export async function listTeamAppleProfiles({
|
|
375
|
-
relay,
|
|
376
|
-
teamId,
|
|
377
|
-
profileKind = 'development',
|
|
378
|
-
}: ListTeamProfilesInput) {
|
|
379
|
-
return listAppleProfiles({ relay, teamId, profileKind });
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/** Read a string-ish value from a loosely typed portal record. */
|
|
383
|
-
export function stringField(record: Record<string, unknown> | undefined, key: string) {
|
|
384
|
-
const value = record?.[key];
|
|
385
|
-
if (typeof value === 'string') return value;
|
|
386
|
-
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
387
|
-
return undefined;
|
|
388
|
-
}
|