From 69fcf17d015ce711d7b2bd9afabb4defa3419e9c Mon Sep 17 00:00:00 2001
From: Bo Anderson <mail@boanderson.me>
Date: Wed, 6 Dec 2023 19:49:20 +0000
Subject: [PATCH] fix: handle clock skew

---
 dist/main.cjs           | 23 ++++++++---------
 lib/main.js             | 24 ++++++++----------
 package-lock.json       | 32 +++++++++++++++++++++--
 package.json            |  1 +
 tests/main-repo-skew.js | 56 +++++++++++++++++++++++++++++++++++++++++
 5 files changed, 107 insertions(+), 29 deletions(-)
 create mode 100644 tests/main-repo-skew.js

diff --git a/dist/main.cjs b/dist/main.cjs
index 266e78e..0d6d1a4 100644
--- a/dist/main.cjs
+++ b/dist/main.cjs
@@ -10390,12 +10390,9 @@ async function main(appId2, privateKey2, owner2, repositories2, core2, createApp
     privateKey: privateKey2,
     request: request2
   });
-  const appAuthentication = await auth({
-    type: "app"
-  });
   let authentication;
   if (parsedRepositoryNames) {
-    authentication = await pRetry(() => getTokenFromRepository(request2, auth, parsedOwner, appAuthentication, parsedRepositoryNames), {
+    authentication = await pRetry(() => getTokenFromRepository(request2, auth, parsedOwner, parsedRepositoryNames), {
       onFailedAttempt: (error) => {
         core2.info(
           `Failed to create token for "${parsedRepositoryNames}" (attempt ${error.attemptNumber}): ${error.message}`
@@ -10404,7 +10401,7 @@ async function main(appId2, privateKey2, owner2, repositories2, core2, createApp
       retries: 3
     });
   } else {
-    authentication = await pRetry(() => getTokenFromOwner(request2, auth, appAuthentication, parsedOwner), {
+    authentication = await pRetry(() => getTokenFromOwner(request2, auth, parsedOwner), {
       onFailedAttempt: (error) => {
         core2.info(
           `Failed to create token for "${parsedOwner}" (attempt ${error.attemptNumber}): ${error.message}`
@@ -10419,19 +10416,19 @@ async function main(appId2, privateKey2, owner2, repositories2, core2, createApp
     core2.saveState("token", authentication.token);
   }
 }
-async function getTokenFromOwner(request2, auth, appAuthentication, parsedOwner) {
+async function getTokenFromOwner(request2, auth, parsedOwner) {
   const response = await request2("GET /orgs/{org}/installation", {
     org: parsedOwner,
-    headers: {
-      authorization: `bearer ${appAuthentication.token}`
+    request: {
+      hook: auth.hook
     }
   }).catch((error) => {
     if (error.status !== 404)
       throw error;
     return request2("GET /users/{username}/installation", {
       username: parsedOwner,
-      headers: {
-        authorization: `bearer ${appAuthentication.token}`
+      request: {
+        hook: auth.hook
       }
     });
   });
@@ -10441,12 +10438,12 @@ async function getTokenFromOwner(request2, auth, appAuthentication, parsedOwner)
   });
   return authentication;
 }
-async function getTokenFromRepository(request2, auth, parsedOwner, appAuthentication, parsedRepositoryNames) {
+async function getTokenFromRepository(request2, auth, parsedOwner, parsedRepositoryNames) {
   const response = await request2("GET /repos/{owner}/{repo}/installation", {
     owner: parsedOwner,
     repo: parsedRepositoryNames.split(",")[0],
-    headers: {
-      authorization: `bearer ${appAuthentication.token}`
+    request: {
+      hook: auth.hook
     }
   });
   const authentication = await auth({
diff --git a/lib/main.js b/lib/main.js
index 9dfe730..233be3d 100644
--- a/lib/main.js
+++ b/lib/main.js
@@ -70,15 +70,11 @@ export async function main(
     request,
   });
 
-  const appAuthentication = await auth({
-    type: "app",
-  });
-
   let authentication;
   // If at least one repository is set, get installation ID from that repository
 
   if (parsedRepositoryNames) {
-    authentication = await pRetry(() => getTokenFromRepository(request, auth, parsedOwner,appAuthentication, parsedRepositoryNames), {
+    authentication = await pRetry(() => getTokenFromRepository(request, auth, parsedOwner, parsedRepositoryNames), {
       onFailedAttempt: (error) => {
         core.info(
           `Failed to create token for "${parsedRepositoryNames}" (attempt ${error.attemptNumber}): ${error.message}`
@@ -89,7 +85,7 @@ export async function main(
 
   } else {
     // Otherwise get the installation for the owner, which can either be an organization or a user account
-    authentication = await pRetry(() => getTokenFromOwner(request, auth, appAuthentication, parsedOwner), {
+    authentication = await pRetry(() => getTokenFromOwner(request, auth, parsedOwner), {
       onFailedAttempt: (error) => {
         core.info(
           `Failed to create token for "${parsedOwner}" (attempt ${error.attemptNumber}): ${error.message}`
@@ -110,12 +106,12 @@ export async function main(
   }
 }
 
-async function getTokenFromOwner(request, auth, appAuthentication, parsedOwner) {
+async function getTokenFromOwner(request, auth, parsedOwner) {
   // https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-an-organization-installation-for-the-authenticated-app
   const response = await request("GET /orgs/{org}/installation", {
     org: parsedOwner,
-    headers: {
-      authorization: `bearer ${appAuthentication.token}`,
+    request: {
+      hook: auth.hook,
     },
   }).catch((error) => {
     /* c8 ignore next */
@@ -124,8 +120,8 @@ async function getTokenFromOwner(request, auth, appAuthentication, parsedOwner)
     // https://docs.github.com/rest/apps/apps?apiVersion=2022-11-28#get-a-user-installation-for-the-authenticated-app
     return request("GET /users/{username}/installation", {
       username: parsedOwner,
-      headers: {
-        authorization: `bearer ${appAuthentication.token}`,
+      request: {
+        hook: auth.hook,
       },
     });
   });
@@ -138,13 +134,13 @@ async function getTokenFromOwner(request, auth, appAuthentication, parsedOwner)
   return authentication;
 }
 
-async function getTokenFromRepository(request, auth, parsedOwner,appAuthentication, parsedRepositoryNames) {
+async function getTokenFromRepository(request, auth, parsedOwner, parsedRepositoryNames) {
   // https://docs.github.com/rest/apps/apps?apiVersion=2022-11-28#get-a-repository-installation-for-the-authenticated-app
   const response = await request("GET /repos/{owner}/{repo}/installation", {
     owner: parsedOwner,
     repo: parsedRepositoryNames.split(",")[0],
-    headers: {
-      authorization: `bearer ${appAuthentication.token}`,
+    request: {
+      hook: auth.hook,
     },
   });
 
diff --git a/package-lock.json b/package-lock.json
index 2ef6e47..664e29d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "create-github-app-token",
-  "version": "1.6.0",
+  "version": "1.6.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "create-github-app-token",
-      "version": "1.6.0",
+      "version": "1.6.1",
       "license": "MIT",
       "dependencies": {
         "@actions/core": "^1.10.1",
@@ -15,6 +15,7 @@
         "p-retry": "^6.1.0"
       },
       "devDependencies": {
+        "@sinonjs/fake-timers": "^11.2.2",
         "ava": "^5.3.1",
         "c8": "^8.0.1",
         "dotenv": "^16.3.1",
@@ -811,6 +812,24 @@
         "@octokit/openapi-types": "^19.0.0"
       }
     },
+    "node_modules/@sinonjs/commons": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz",
+      "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==",
+      "dev": true,
+      "dependencies": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "node_modules/@sinonjs/fake-timers": {
+      "version": "11.2.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz",
+      "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^3.0.0"
+      }
+    },
     "node_modules/@tokenizer/token": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@@ -4089,6 +4108,15 @@
         "node": ">=0.6.11 <=0.7.0 || >=0.7.3"
       }
     },
+    "node_modules/type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/type-fest": {
       "version": "0.13.1",
       "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
diff --git a/package.json b/package.json
index e3b0b3f..b6459aa 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
     "p-retry": "^6.1.0"
   },
   "devDependencies": {
+    "@sinonjs/fake-timers": "^11.2.2",
     "ava": "^5.3.1",
     "c8": "^8.0.1",
     "dotenv": "^16.3.1",
diff --git a/tests/main-repo-skew.js b/tests/main-repo-skew.js
new file mode 100644
index 0000000..a3554ad
--- /dev/null
+++ b/tests/main-repo-skew.js
@@ -0,0 +1,56 @@
+import { test } from "./main.js";
+
+import { install } from "@sinonjs/fake-timers";
+
+// Verify `main` retry when the clock has drifted.
+await test((mockPool) => {
+  process.env.INPUT_OWNER = 'actions'
+  process.env.INPUT_REPOSITORIES = 'failed-repo';
+  const owner = process.env.INPUT_OWNER
+  const repo = process.env.INPUT_REPOSITORIES
+  const mockInstallationId = "123456";
+
+  install({ now: 0, toFake: ["Date"] });
+
+  mockPool
+    .intercept({
+      path: `/repos/${owner}/${repo}/installation`,
+      method: "GET",
+      headers: {
+        accept: "application/vnd.github.v3+json",
+        "user-agent": "actions/create-github-app-token",
+        // Intentionally omitting the `authorization` header, since JWT creation is not idempotent.
+      },
+    })
+    .reply(({ headers }) => {
+      const [_, jwt] = (headers.authorization || "").split(" ");
+      const payload = JSON.parse(Buffer.from(jwt.split(".")[1], "base64").toString());
+
+      if (payload.iat < 0) {
+        return {
+          statusCode: 401,
+          data: {
+            message: "'Issued at' claim ('iat') must be an Integer representing the time that the assertion was issued."
+          },
+          responseOptions: {
+            headers: {
+              "content-type": "application/json",
+              "date": new Date(Date.now() + 30000).toUTCString()
+            }
+          }
+        };
+      }
+
+      return {
+        statusCode: 200,
+        data: {
+          id: mockInstallationId
+        },
+        responseOptions: {
+          headers: {
+            "content-type": "application/json"
+          }
+        }
+      };
+    }).times(2);
+});