Skip to content
Open
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
129 changes: 35 additions & 94 deletions lambdas/functions/control-plane/src/github/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { createAppAuth, type AppAuthentication, type InstallationAccessTokenAuthentication } from '@octokit/auth-app';
import type { OctokitOptions, Octokit as CoreOctokit } from '@octokit/core';
import type { RequestInterface } from '@octokit/types';
import { createSign, randomUUID } from 'node:crypto';
import { request } from '@octokit/request';
import { Octokit } from '@octokit/rest';
import { retry } from '@octokit/plugin-retry';
import { throttling } from '@octokit/plugin-throttling';
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
import { createGitHubAppCredentialsStore, type GitHubAppCredential } from '@aws-github-runner/storage-providers';
import { EndpointDefaults } from '@octokit/types';

// Define types that are not directly exported
type AppAuthOptions = { type: 'app' };
type InstallationAuthOptions = { type: 'installation'; installationId?: number };
// Use a more generalized AuthInterface to match what createAppAuth returns
type AuthInterface = {
(options: AppAuthOptions): Promise<AppAuthentication>;
(options: InstallationAuthOptions): Promise<InstallationAccessTokenAuthentication>;
Expand All @@ -16,33 +22,14 @@ type StrategyOptions = {
installationId?: number;
request?: RequestInterface;
};
import { createSign, randomUUID } from 'node:crypto';
import { request } from '@octokit/request';
import { Octokit } from '@octokit/rest';
import { retry } from '@octokit/plugin-retry';
import { throttling } from '@octokit/plugin-throttling';
import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
import { getParameters } from '@aws-github-runner/aws-ssm-util';
import { EndpointDefaults } from '@octokit/types';

const logger = createChildLogger('gh-auth');

// Retry caps for the throttling plugin. Returning `true` from a limit handler tells
// the plugin to retry after the interval GitHub asked for; returning `false` gives up.
// Primary rate limits reset on a fixed schedule, so a couple of retries is worthwhile.
// Secondary rate limits are abuse-detection signals — retry once, then back off and
// let the message return to the queue rather than pushing harder.
const MAX_RATE_LIMIT_RETRIES = 2;
const MAX_SECONDARY_RATE_LIMIT_RETRIES = 1;

// Exported for tests: the plugin only surfaces these via the client constructor,
// so there is no other seam to assert the retry cap against.
export function onRateLimit(
retryAfter: number,
options: Required<EndpointDefaults>,
// The throttling plugin types this as @octokit/core's Octokit, not the wider
// @octokit/rest one imported above; matching it keeps the handler assignable to
// the plugin's LimitHandler. Unused here regardless.
_octokit: CoreOctokit,
retryCount: number,
): boolean {
Expand All @@ -56,9 +43,6 @@ export function onRateLimit(
export function onSecondaryRateLimit(
retryAfter: number,
options: Required<EndpointDefaults>,
// The throttling plugin types this as @octokit/core's Octokit, not the wider
// @octokit/rest one imported above; matching it keeps the handler assignable to
// the plugin's LimitHandler. Unused here regardless.
_octokit: CoreOctokit,
retryCount: number,
): boolean {
Expand All @@ -69,52 +53,10 @@ export function onSecondaryRateLimit(
return retryCount < MAX_SECONDARY_RATE_LIMIT_RETRIES;
}

interface GitHubAppCredential {
appId: number;
privateKey: string;
installationId?: number;
}

let appCredentialsPromise: Promise<GitHubAppCredential[]> | null = null;

async function loadAppCredentials(): Promise<GitHubAppCredential[]> {
if (!process.env.PARAMETER_GITHUB_APP_ID_NAME) {
throw new Error('Environment variable PARAMETER_GITHUB_APP_ID_NAME is not set');
}
if (!process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME) {
throw new Error('Environment variable PARAMETER_GITHUB_APP_KEY_BASE64_NAME is not set');
}
const idParams = process.env.PARAMETER_GITHUB_APP_ID_NAME.split(':').filter(Boolean);
const keyParams = process.env.PARAMETER_GITHUB_APP_KEY_BASE64_NAME.split(':').filter(Boolean);
const installationIdParams = (process.env.PARAMETER_GITHUB_APP_INSTALLATION_ID_NAME || '').split(':');
if (idParams.length !== keyParams.length) {
throw new Error(`GitHub App parameter count mismatch: ${idParams.length} IDs vs ${keyParams.length} keys`);
}
// Batch fetch all SSM parameters in a single call to reduce API calls
const allParamNames = [...idParams, ...keyParams, ...installationIdParams.filter((p) => p.length > 0)];
const params = await getParameters(allParamNames);

const credentials: GitHubAppCredential[] = [];
for (let i = 0; i < idParams.length; i++) {
const appIdValue = params.get(idParams[i]);
if (!appIdValue) {
throw new Error(`Parameter ${idParams[i]} not found`);
}
const appId = parseInt(appIdValue, 10);
const privateKeyBase64 = params.get(keyParams[i]);
if (!privateKeyBase64) {
throw new Error(`Parameter ${keyParams[i]} not found`);
}
// replace literal \n characters with new lines to allow the key to be stored as a
// single line variable. This logic should match how the GitHub Terraform provider
// processes private keys to retain compatibility between the projects
const privateKey = Buffer.from(privateKeyBase64, 'base64').toString().replace(/\\n/g, '\n');
const installationIdParam = installationIdParams[i];
const installationIdValue =
installationIdParam && installationIdParam.length > 0 ? params.get(installationIdParam) : undefined;
const installationId = installationIdValue ? parseInt(installationIdValue, 10) : undefined;
credentials.push({ appId, privateKey, installationId });
}
const credentials = await createGitHubAppCredentialsStore().get();
logger.info(`Loaded ${credentials.length} GitHub App credential(s)`);
return credentials;
}
Expand All @@ -137,34 +79,37 @@ export async function getStoredInstallationId(appIndex: number): Promise<number
return credentials[appIndex]?.installationId;
}

export async function getAppId(appIndex = 0): Promise<string> {
const credential = (await getAppCredentials())[appIndex];
if (!credential) {
throw new Error(`GitHub App credential at index ${appIndex} not found`);
}
return credential.appId.toString();
}

export async function createOctokitClient(token: string, ghesApiUrl = ''): Promise<Octokit> {
const CustomOctokit = Octokit.plugin(retry, throttling);
const ocktokitOptions: OctokitOptions = {
auth: token,
};
const octokitOptions: OctokitOptions = { auth: token };
if (ghesApiUrl) {
ocktokitOptions.baseUrl = ghesApiUrl;
ocktokitOptions.previews = ['antiope'];
octokitOptions.baseUrl = ghesApiUrl;
octokitOptions.previews = ['antiope'];
}

return new CustomOctokit({
...ocktokitOptions,
...octokitOptions,
userAgent: process.env.USER_AGENT || 'github-aws-runners',
retry: {
onRetry: (retryCount: number, error: Error, request: { method: string; url: string }) => {
onRetry: (retryCount: number, error: Error, retryRequest: { method: string; url: string }) => {
logger.warn('GitHub API request retry attempt', {
retryCount,
method: request.method,
url: request.url,
method: retryRequest.method,
url: retryRequest.url,
error: error.message,
status: (error as Error & { status?: number }).status,
});
},
},
throttle: {
onRateLimit,
onSecondaryRateLimit,
},
throttle: { onRateLimit, onSecondaryRateLimit },
});
}

Expand All @@ -176,8 +121,7 @@ export async function createGithubAppAuth(
const credentials = await getAppCredentials();
const idx = appIndex ?? Math.floor(Math.random() * credentials.length);
const auth = await createAuth(installationId, ghesApiUrl, idx);
const result = await auth({ type: 'app' });
return { ...result, appIndex: idx };
return { ...(await auth({ type: 'app' })), appIndex: idx };
}

export async function createGithubInstallationAuth(
Expand Down Expand Up @@ -207,13 +151,11 @@ async function createAuth(
const credentials = await getAppCredentials();
const selected =
appIndex !== undefined ? credentials[appIndex] : credentials[Math.floor(Math.random() * credentials.length)];
if (!selected) {
throw new Error(`GitHub App credential at index ${appIndex ?? 0} not found`);
}

logger.debug(`Selected GitHub App ${selected.appId} for authentication`);

// Use a custom createJwt callback to include a jti (JWT ID) claim in every token.
// Without this, concurrent Lambda invocations generating JWTs within the same second
// produce byte-identical tokens (same iat, exp, iss), which GitHub rejects as duplicates.
// See: https://github.com/github-aws-runners/terraform-aws-github-runner/issues/5025
const createJwt = async (appId: string | number, timeDifference?: number) => {
const now = Math.floor(Date.now() / 1000) + (timeDifference ?? 0);
const iat = now - 30;
Expand All @@ -222,14 +164,13 @@ async function createAuth(
return { jwt, expiresAt: new Date(exp * 1000).toISOString() };
};

let authOptions: StrategyOptions = { appId: selected.appId, createJwt };
if (installationId) authOptions = { ...authOptions, installationId };

logger.debug(`GHES API URL: ${ghesApiUrl}`);
const authOptions: StrategyOptions = {
appId: selected.appId,
createJwt,
...(installationId ? { installationId } : {}),
};
if (ghesApiUrl) {
authOptions.request = request.defaults({
baseUrl: ghesApiUrl,
});
authOptions.request = request.defaults({ baseUrl: ghesApiUrl });
}
return createAppAuth(authOptions);
}
Loading