Skip to content

Commit 47b53cb

Browse files
refactor(storage): extract GitHub App credentials
1 parent 66a0527 commit 47b53cb

17 files changed

Lines changed: 486 additions & 346 deletions

lambdas/functions/control-plane/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
},
3232
"dependencies": {
3333
"@aws-github-runner/aws-powertools-util": "*",
34-
"@aws-github-runner/aws-ssm-util": "*",
3534
"@aws-github-runner/compute-providers": "*",
3635
"@aws-github-runner/storage-providers": "*",
3736
"@aws-lambda-powertools/parameters": "^2.31.0",

lambdas/functions/control-plane/src/github/auth.test.ts

Lines changed: 56 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ import { createAppAuth } from '@octokit/auth-app';
22
import { StrategyOptions } from '@octokit/auth-app/dist-types/types';
33
import { request } from '@octokit/request';
44
import { RequestInterface, RequestParameters } from '@octokit/types';
5-
import { getParameters } from '@aws-github-runner/aws-ssm-util';
5+
import {
6+
getGitHubAppCredentialsStore,
7+
type GitHubAppCredential,
8+
type GitHubAppCredentialsStore,
9+
} from '@aws-github-runner/storage-providers';
610
import { generateKeyPairSync } from 'node:crypto';
711
import * as nock from 'nock';
812

913
import {
1014
createGithubAppAuth,
1115
createOctokitClient,
16+
getAppCount,
17+
getAppId,
1218
getStoredInstallationId,
1319
onRateLimit,
1420
onSecondaryRateLimit,
@@ -25,24 +31,27 @@ type MockProxy<T> = T & {
2531
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2632
const mock = <T>(implementation?: any): MockProxy<T> => vi.fn(implementation) as any;
2733

28-
vi.mock('@aws-github-runner/aws-ssm-util');
34+
vi.mock('@aws-github-runner/storage-providers', () => ({
35+
getGitHubAppCredentialsStore: vi.fn(),
36+
}));
2937
vi.mock('@octokit/auth-app');
3038

3139
const cleanEnv = process.env;
32-
const ENVIRONMENT = 'dev';
33-
const GITHUB_APP_ID = '1';
34-
const PARAMETER_GITHUB_APP_ID_NAME = `/actions-runner/${ENVIRONMENT}/github_app_id`;
35-
const PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`;
40+
const GITHUB_APP_ID = 1;
3641

37-
const mockedGetParameters = vi.mocked(getParameters);
42+
const mockedGetGitHubAppCredentialsStore = vi.mocked(getGitHubAppCredentialsStore);
43+
const mockCredentialsGet = vi.fn<GitHubAppCredentialsStore['get']>();
44+
const credentialsStore = {
45+
get: mockCredentialsGet,
46+
} satisfies GitHubAppCredentialsStore;
3847

3948
beforeEach(() => {
4049
vi.resetModules();
4150
vi.clearAllMocks();
51+
mockCredentialsGet.mockReset();
4252
resetAppCredentialsCache();
4353
process.env = { ...cleanEnv };
44-
process.env.PARAMETER_GITHUB_APP_ID_NAME = PARAMETER_GITHUB_APP_ID_NAME;
45-
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = PARAMETER_GITHUB_APP_KEY_BASE64_NAME;
54+
mockedGetGitHubAppCredentialsStore.mockReturnValue(credentialsStore);
4655
nock.disableNetConnect();
4756
});
4857

@@ -80,38 +89,18 @@ describe('Test createGithubAppAuth', () => {
8089
const authType = 'app';
8190
const token = '123456';
8291
const decryptedValue = 'decryptedValue';
83-
const b64 = Buffer.from(decryptedValue, 'binary').toString('base64');
84-
85-
beforeEach(() => {
86-
process.env.ENVIRONMENT = ENVIRONMENT;
87-
});
8892

89-
it('Throws early when PARAMETER_GITHUB_APP_ID_NAME is not set', async () => {
90-
delete process.env.PARAMETER_GITHUB_APP_ID_NAME;
93+
it('Propagates errors from the credential store', async () => {
94+
const error = new Error('Unable to load GitHub App credentials');
95+
mockCredentialsGet.mockRejectedValueOnce(error);
9196

92-
await expect(createGithubAppAuth(installationId)).rejects.toThrow(
93-
'Environment variable PARAMETER_GITHUB_APP_ID_NAME is not set',
94-
);
95-
expect(mockedGetParameters).not.toHaveBeenCalled();
96-
});
97-
98-
it('Throws early when PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set', async () => {
99-
delete process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME;
100-
101-
await expect(createGithubAppAuth(installationId)).rejects.toThrow(
102-
'Environment variable PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set',
103-
);
104-
expect(mockedGetParameters).not.toHaveBeenCalled();
97+
await expect(createGithubAppAuth(installationId)).rejects.toBe(error);
98+
expect(mockCredentialsGet).toHaveBeenCalledOnce();
10599
});
106100

107101
it('Creates auth object with createJwt callback including jti claim', async () => {
108102
// Arrange
109-
mockedGetParameters.mockResolvedValueOnce(
110-
new Map([
111-
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
112-
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
113-
]),
114-
);
103+
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]);
115104

116105
const mockedAuth = vi.fn();
117106
mockedAuth.mockResolvedValue({ token });
@@ -124,7 +113,7 @@ describe('Test createGithubAppAuth', () => {
124113
// Assert
125114
expect(mockedCreatAppAuth).toBeCalledTimes(1);
126115
const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record<string, unknown>;
127-
expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID));
116+
expect(callArgs.appId).toBe(GITHUB_APP_ID);
128117
expect(callArgs.createJwt).toBeTypeOf('function');
129118
expect(callArgs).not.toHaveProperty('privateKey');
130119
expect(callArgs.installationId).toBe(installationId);
@@ -137,14 +126,7 @@ describe('Test createGithubAppAuth', () => {
137126
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
138127
publicKeyEncoding: { type: 'spki', format: 'pem' },
139128
});
140-
const b64Key = Buffer.from(privateKey as string).toString('base64');
141-
142-
mockedGetParameters.mockResolvedValueOnce(
143-
new Map([
144-
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
145-
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64Key],
146-
]),
147-
);
129+
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: privateKey as string }]);
148130

149131
let capturedCreateJwt: (appId: string | number, timeDifference?: number) => Promise<{ jwt: string }>;
150132
mockedCreatAppAuth.mockImplementation((opts: StrategyOptions) => {
@@ -173,41 +155,9 @@ describe('Test createGithubAppAuth', () => {
173155
expect(payload).toHaveProperty('iss');
174156
});
175157

176-
it('Creates auth object with line breaks in SSH key.', async () => {
177-
// Arrange
178-
const b64PrivateKeyWithLineBreaks = Buffer.from(decryptedValue + '\n' + decryptedValue, 'binary').toString(
179-
'base64',
180-
);
181-
mockedGetParameters.mockResolvedValueOnce(
182-
new Map([
183-
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
184-
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64PrivateKeyWithLineBreaks],
185-
]),
186-
);
187-
188-
const mockedAuth = vi.fn();
189-
mockedAuth.mockResolvedValue({ token });
190-
const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() });
191-
mockedCreatAppAuth.mockReturnValue(mockWithHook);
192-
193-
// Act
194-
const result = await createGithubAppAuth(installationId);
195-
196-
// Assert
197-
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);
198-
expect(mockedCreatAppAuth).toBeCalledTimes(1);
199-
expect(mockedAuth).toBeCalledWith({ type: authType });
200-
expect(result.token).toBe(token);
201-
});
202-
203158
it('Creates auth object for public GitHub', async () => {
204159
// Arrange
205-
mockedGetParameters.mockResolvedValueOnce(
206-
new Map([
207-
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
208-
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
209-
]),
210-
);
160+
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]);
211161

212162
const mockedAuth = vi.fn();
213163
mockedAuth.mockResolvedValue({ token });
@@ -218,11 +168,9 @@ describe('Test createGithubAppAuth', () => {
218168
const result = await createGithubAppAuth(installationId);
219169

220170
// Assert
221-
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);
222-
223171
expect(mockedCreatAppAuth).toBeCalledTimes(1);
224172
const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record<string, unknown>;
225-
expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID));
173+
expect(callArgs.appId).toBe(GITHUB_APP_ID);
226174
expect(callArgs.createJwt).toBeTypeOf('function');
227175
expect(callArgs.installationId).toBe(installationId);
228176
expect(mockedAuth).toBeCalledWith({ type: authType });
@@ -238,12 +186,7 @@ describe('Test createGithubAppAuth', () => {
238186
() => mockedRequestInterface as RequestInterface<object & RequestParameters>,
239187
);
240188

241-
mockedGetParameters.mockResolvedValueOnce(
242-
new Map([
243-
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
244-
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
245-
]),
246-
);
189+
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]);
247190
const mockedAuth = vi.fn();
248191
mockedAuth.mockResolvedValue({ token });
249192
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -255,11 +198,9 @@ describe('Test createGithubAppAuth', () => {
255198
const result = await createGithubAppAuth(installationId, githubServerUrl);
256199

257200
// Assert
258-
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);
259-
260201
expect(mockedCreatAppAuth).toBeCalledTimes(1);
261202
const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record<string, unknown>;
262-
expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID));
203+
expect(callArgs.appId).toBe(GITHUB_APP_ID);
263204
expect(callArgs.createJwt).toBeTypeOf('function');
264205
expect(callArgs.installationId).toBe(installationId);
265206
expect(callArgs.request).toBeDefined();
@@ -278,12 +219,7 @@ describe('Test createGithubAppAuth', () => {
278219

279220
const installationId = undefined;
280221

281-
mockedGetParameters.mockResolvedValueOnce(
282-
new Map([
283-
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
284-
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
285-
]),
286-
);
222+
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: decryptedValue }]);
287223
const mockedAuth = vi.fn();
288224
mockedAuth.mockResolvedValue({ token });
289225
const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() });
@@ -293,11 +229,9 @@ describe('Test createGithubAppAuth', () => {
293229
const result = await createGithubAppAuth(installationId, githubServerUrl);
294230

295231
// Assert
296-
expect(getParameters).toBeCalledWith([PARAMETER_GITHUB_APP_ID_NAME, PARAMETER_GITHUB_APP_KEY_BASE64_NAME]);
297-
298232
expect(mockedCreatAppAuth).toBeCalledTimes(1);
299233
const callArgs = mockedCreatAppAuth.mock.calls[0][0] as Record<string, unknown>;
300-
expect(callArgs.appId).toBe(parseInt(GITHUB_APP_ID));
234+
expect(callArgs.appId).toBe(GITHUB_APP_ID);
301235
expect(callArgs.createJwt).toBeTypeOf('function');
302236
expect(callArgs).not.toHaveProperty('installationId');
303237
expect(callArgs.request).toBeDefined();
@@ -330,98 +264,48 @@ describe('Test throttling retry caps', () => {
330264
});
331265
});
332266

333-
describe('Test getStoredInstallationId', () => {
334-
const decryptedValue = 'decryptedValue';
335-
const b64 = Buffer.from(decryptedValue, 'binary').toString('base64');
336-
337-
beforeEach(() => {
338-
const mockedAuth = vi.fn();
339-
mockedAuth.mockResolvedValue({ token: 'token' });
340-
const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() });
341-
vi.mocked(createAppAuth).mockReturnValue(mockWithHook);
342-
});
343-
267+
describe('Test GitHub App credential accessors', () => {
344268
it('returns stored installation ID when configured', async () => {
345-
const installationIdParam = `/actions-runner/${ENVIRONMENT}/github_app_installation_id`;
346-
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = installationIdParam;
347-
mockedGetParameters.mockResolvedValueOnce(
348-
new Map([
349-
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
350-
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
351-
[installationIdParam, '12345'],
352-
]),
353-
);
269+
mockCredentialsGet.mockResolvedValueOnce([
270+
{ appId: GITHUB_APP_ID, privateKey: 'private-key', installationId: 12345 },
271+
]);
354272

355273
const result = await getStoredInstallationId(0);
356274
expect(result).toBe(12345);
357275
});
358276

359-
it('returns undefined when installation ID param is empty', async () => {
360-
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = '';
361-
mockedGetParameters.mockResolvedValueOnce(
362-
new Map([
363-
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
364-
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
365-
]),
366-
);
367-
368-
const result = await getStoredInstallationId(0);
369-
expect(result).toBeUndefined();
370-
});
371-
372-
it('returns undefined when env var is not set', async () => {
373-
delete process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME;
374-
mockedGetParameters.mockResolvedValueOnce(
375-
new Map([
376-
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
377-
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
378-
]),
379-
);
277+
it('returns undefined when the credential has no installation ID', async () => {
278+
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: 'private-key' }]);
380279

381280
const result = await getStoredInstallationId(0);
382281
expect(result).toBeUndefined();
383282
});
384283

385284
it('returns undefined for out-of-bounds appIndex', async () => {
386-
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = '';
387-
mockedGetParameters.mockResolvedValueOnce(
388-
new Map([
389-
[PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
390-
[PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
391-
]),
392-
);
285+
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: 'private-key' }]);
393286

394287
const result = await getStoredInstallationId(99);
395288
expect(result).toBeUndefined();
396289
});
397290

398-
it('loads installation IDs for multi-app setup', async () => {
399-
const app1IdParam = `/actions-runner/${ENVIRONMENT}/github_app_id`;
400-
const app2IdParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_id`;
401-
const app1KeyParam = `/actions-runner/${ENVIRONMENT}/github_app_key_base64`;
402-
const app2KeyParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_key_base64`;
403-
const app2InstallParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_installation_id`;
404-
405-
process.env.PARAMETER_GITHUB_APP_ID_NAME = `${app1IdParam}:${app2IdParam}`;
406-
process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME = `${app1KeyParam}:${app2KeyParam}`;
407-
process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME = `:${app2InstallParam}`;
408-
409-
mockedGetParameters.mockResolvedValueOnce(
410-
new Map([
411-
[app1IdParam, '1'],
412-
[app1KeyParam, b64],
413-
[app2IdParam, '2'],
414-
[app2KeyParam, b64],
415-
[app2InstallParam, '67890'],
416-
]),
417-
);
291+
it('loads multi-app credentials once and exposes values by index', async () => {
292+
const credentials: GitHubAppCredential[] = [
293+
{ appId: 1, privateKey: 'private-key-1' },
294+
{ appId: 2, privateKey: 'private-key-2', installationId: 67890 },
295+
];
296+
mockCredentialsGet.mockResolvedValueOnce(credentials);
297+
298+
await expect(getAppCount()).resolves.toBe(2);
299+
await expect(getAppId()).resolves.toBe('1');
300+
await expect(getAppId(1)).resolves.toBe('2');
301+
await expect(getStoredInstallationId(0)).resolves.toBeUndefined();
302+
await expect(getStoredInstallationId(1)).resolves.toBe(67890);
303+
expect(mockCredentialsGet).toHaveBeenCalledOnce();
304+
});
418305

419-
// Primary app (index 0) has no stored installation ID
420-
const result0 = await getStoredInstallationId(0);
421-
expect(result0).toBeUndefined();
306+
it('throws a clear error for an out-of-bounds app ID index', async () => {
307+
mockCredentialsGet.mockResolvedValueOnce([{ appId: GITHUB_APP_ID, privateKey: 'private-key' }]);
422308

423-
// Additional app (index 1) has stored installation ID
424-
const result1 = await getStoredInstallationId(1);
425-
expect(result1).toBe(67890);
309+
await expect(getAppId(99)).rejects.toThrow('GitHub App credential at index 99 not found');
426310
});
427311
});

0 commit comments

Comments
 (0)