diff --git a/src/config/feature-flags.ts b/src/config/feature-flags.ts index cb59ce4ea..5bece05be 100644 --- a/src/config/feature-flags.ts +++ b/src/config/feature-flags.ts @@ -42,7 +42,8 @@ export enum NumberFlags { NUMBER_OF_BUILD_PAGES_TO_FETCH_IN_PARALLEL = "number-of-build-to-fetch-in-parallel", BACKFILL_PAGE_SIZE = "backfill-page-size", INSTALLATION_TOKEN_CACHE_MAX_SIZE = "installation-token-cache-max-size", - SKIP_PROCESS_QUEUE_IF_ISSUE_NOT_FOUND_TIMEOUT = "skip-process-queue-when-issue-not-exists-timeout" + SKIP_PROCESS_QUEUE_IF_ISSUE_NOT_FOUND_TIMEOUT = "skip-process-queue-when-issue-not-exists-timeout", + APP_TOKEN_EXP_IN_MILLI_SEC = "app-token-exp-in-milli-sec" } const createLaunchdarklyUser = (key?: string): LDUser => { diff --git a/src/github/client/app-token-holder.ts b/src/github/client/app-token-holder.ts index 77f2c95ca..3b641dbe6 100644 --- a/src/github/client/app-token-holder.ts +++ b/src/github/client/app-token-holder.ts @@ -3,6 +3,7 @@ import { AuthToken, ONE_MINUTE, TEN_MINUTES } from "./auth-token"; import LRUCache from "lru-cache"; import { InstallationId } from "./installation-id"; import { keyLocator } from "~/src/github/client/key-locator"; +import { numberFlag, NumberFlags } from "config/feature-flags"; /** * Holds app tokens for all GitHub apps that are connected and creates new tokens if necessary. @@ -31,9 +32,9 @@ export class AppTokenHolder { /** * Generates a JWT using the private key of the GitHub app to authorize against the GitHub API. */ - public static createAppJwt(key: string, appId: string): AuthToken { + public static createAppJwt(key: string, appId: string, expTimeInMillSec: number | undefined): AuthToken { - const expirationDate = new Date(Date.now() + TEN_MINUTES); + const expirationDate = new Date(Date.now() + (expTimeInMillSec || TEN_MINUTES)); const jwtPayload = { // "issued at" date, 60 seconds into the past to allow for some time drift @@ -60,7 +61,8 @@ export class AppTokenHolder { if (!key) { throw new Error(`No private key found for GitHub app ${appId.toString()}`); } - currentToken = AppTokenHolder.createAppJwt(key, appId.appId.toString()); + const expTimeInMillSec = await numberFlag(NumberFlags.APP_TOKEN_EXP_IN_MILLI_SEC, NaN, jiraHost); + currentToken = AppTokenHolder.createAppJwt(key, appId.appId.toString(), expTimeInMillSec); this.appTokenCache.set(appId.toString(), currentToken); } return currentToken; diff --git a/src/github/client/github-app-client.test.ts b/src/github/client/github-app-client.test.ts new file mode 100644 index 000000000..4ca7d6c17 --- /dev/null +++ b/src/github/client/github-app-client.test.ts @@ -0,0 +1,97 @@ +import { GitHubAppClient } from "./github-app-client"; +import { numberFlag, NumberFlags } from "config/feature-flags"; +import { when } from "jest-when"; +import { getLogger } from "config/logger"; +import fs from "fs"; +import path from "path"; +import { envVars } from "config/env"; + +jest.mock("config/feature-flags"); +const log = getLogger("test"); + +const APP_ID = "11111"; +const TEN_MINUTES = 10 * 60 * 1000; +const ONE_SEC_IN_MILLISE = 1000; +const PRIVATE_KEY = fs.readFileSync(path.join(process.cwd(), envVars.PRIVATE_KEY_PATH), "utf-8"); + +describe("GitHubAppClient", () => { + describe("App token", () => { + describe("With new exp time settings in ff turn on", () => { + beforeEach(async () => { + when(numberFlag) + .calledWith(NumberFlags.APP_TOKEN_EXP_IN_MILLI_SEC, expect.anything(), expect.anything()) + .mockResolvedValue(ONE_SEC_IN_MILLISE); + }); + it("should use the the provided exp timeout", async () => { + + let expTimeInMillSec: number | undefined; + + githubNock.get("/app") + .matchHeader("Authorization", (authTokenBearer) => { + const token = authTokenBearer.substring("Bearer ".length); + const parts = token.split(".").map(p => Buffer.from(p, "base64").toString()); + expTimeInMillSec = JSON.parse(parts[1]).exp * 1000; + return true; + }) + .reply(200, { + name: "app1" + }); + + const startTime = new Date().getTime(); + const appClient = new GitHubAppClient({ + apiUrl: "https://api.github.com", + baseUrl: "https://api.github.com", + graphqlUrl: "https://api.github.com/graphql", + hostname: "github.com" + }, jiraHost, { trigger: "test" }, log, APP_ID, PRIVATE_KEY); + + const app = (await appClient.getApp()).data; + + expect(app).toMatchObject({ + name: "app1" + }); + + expect(expTimeInMillSec).toBeDefined(); + const diff = expTimeInMillSec!- startTime; + expect(diff).toBeGreaterThan(0); + expect(diff).toBeLessThanOrEqual(ONE_SEC_IN_MILLISE); + }); + }); + describe("With new exp time settings in ff turn off", () => { + it("should use the the buildin exp timeout", async () => { + + let expTimeInMillSec: number | undefined; + + githubNock.get("/app") + .matchHeader("Authorization", (authTokenBearer) => { + const token = authTokenBearer.substring("Bearer ".length); + const parts = token.split(".").map(p => Buffer.from(p, "base64").toString()); + expTimeInMillSec = JSON.parse(parts[1]).exp * 1000; + return true; + }) + .reply(200, { + name: "app1" + }); + + const startTime = new Date().getTime(); + const appClient = new GitHubAppClient({ + apiUrl: "https://api.github.com", + baseUrl: "https://api.github.com", + graphqlUrl: "https://api.github.com/graphql", + hostname: "github.com" + }, jiraHost, { trigger: "test" }, log, APP_ID, PRIVATE_KEY); + + const app = (await appClient.getApp()).data; + + expect(app).toMatchObject({ + name: "app1" + }); + + expect(expTimeInMillSec).toBeDefined(); + const diff = expTimeInMillSec!- startTime; + expect(diff).toBeGreaterThan(0); + expect(Math.abs(TEN_MINUTES - diff)).toBeLessThan(1000); + }); + }); + }); +}); diff --git a/src/github/client/github-app-client.ts b/src/github/client/github-app-client.ts index 01c04041e..198018ff7 100644 --- a/src/github/client/github-app-client.ts +++ b/src/github/client/github-app-client.ts @@ -5,7 +5,7 @@ import { AppTokenHolder } from "./app-token-holder"; import { AuthToken } from "~/src/github/client/auth-token"; import { GITHUB_ACCEPT_HEADER } from "./github-client-constants"; import { GitHubClient, GitHubConfig, Metrics } from "./github-client"; - +import { numberFlag, NumberFlags } from "config/feature-flags"; /** * A GitHub client that supports authentication as a GitHub app. * This is the top level app API: get all installations of this app, or get more info on this app @@ -13,7 +13,8 @@ import { GitHubClient, GitHubConfig, Metrics } from "./github-client"; * @see https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps */ export class GitHubAppClient extends GitHubClient { - private readonly appToken: AuthToken; + + private readonly getAppToken: () => Promise; constructor( gitHubConfig: GitHubConfig, @@ -24,14 +25,18 @@ export class GitHubAppClient extends GitHubClient { privateKey: string ) { super(gitHubConfig, jiraHost, metrics, logger); - this.appToken = AppTokenHolder.createAppJwt(privateKey, appId); - this.axios.interceptors.request.use((config: AxiosRequestConfig) => { + this.getAppToken = async () => { + const expTimeInMillSec = await numberFlag(NumberFlags.APP_TOKEN_EXP_IN_MILLI_SEC, NaN, jiraHost); + return AppTokenHolder.createAppJwt(privateKey, appId, expTimeInMillSec); + }; + + this.axios.interceptors.request.use(async (config: AxiosRequestConfig) => { return { ...config, headers: { ...config.headers, - ...this.appAuthenticationHeaders() + ...await this.appAuthenticationHeaders() } }; }); @@ -53,10 +58,10 @@ export class GitHubAppClient extends GitHubClient { /** * Use this config in a request to authenticate with the app token. */ - private appAuthenticationHeaders(): Partial { + private async appAuthenticationHeaders(): Promise> { return { Accept: GITHUB_ACCEPT_HEADER, - Authorization: `Bearer ${this.appToken.token}` + Authorization: `Bearer ${(await this.getAppToken()).token}` }; } diff --git a/src/routes/api/ghes-app-verification/ghes-app-verify-get-apps.ts b/src/routes/api/ghes-app-verification/ghes-app-verify-get-apps.ts index 5c81c057e..64dee893f 100644 --- a/src/routes/api/ghes-app-verification/ghes-app-verify-get-apps.ts +++ b/src/routes/api/ghes-app-verification/ghes-app-verify-get-apps.ts @@ -5,6 +5,7 @@ import { Installation } from "models/installation"; import { runCurl } from "utils/curl/curl-utils"; import { AppTokenHolder } from "~/src/github/client/app-token-holder"; +const FIVE_MINUTES_IN_MILLI_SEC = 5 * 60 * 1000; export const GHESVerifyGetApps = async (req: Request, res: Response): Promise => { const gitHubAppId = parseInt(req.params["gitHubAppId"] || ""); @@ -36,7 +37,7 @@ export const GHESVerifyGetApps = async (req: Request, res: Response): Promise