DEV Community

Cover image for GitHub App and OAuth ~ Disjointed flow
Francesco Di Donato
Francesco Di Donato

Posted on

GitHub App and OAuth ~ Disjointed flow

In a previous post we saw how it's possible to get information from the GitHub REST API about which repositories have our GitHub App installed. This allowes us to build something like the following component.

Vercel's Import Git Repository component

Vercel's Import Git Repository component

Since this data is related to a GitHub App, the necessary token is issued by the OAuth included in the GitHub App in question.

Q: My system is already based on authentication through an OAuth App, I cannot twist it! Also, we it is handled by a third-party (i.g. Stytch). But still, the access_token issued by an OAuth is not valid for the two endpoints!

A: There is a solution. It's slightly more expensive than the previous one, but it works.

Index

  1. Create OAuth App and GitHub App
  2. Authenticate as GitHub App via JWT
  3. Get installation_ids of the GitHub App
  4. Get GitHub App's access_tokens
  5. Retrieve eligible repositories
  6. Related Posts

Create OAuth App and GitHub App

Obviously it's necessary to create the OAuth App and also create the GitHub App.

GitHub OAuth App creation screen

  • Homepage URL: http://localhost:3000
  • Callback URL: Where the provider should send back the user once the authentication flow is completed. You can pick any route, I'm using /oauth/github/login/callback

Finalize the creation. It has been assigned an Client ID and it's possible to generate a Client Secret. Please do it and keep them handy.

Similar to how explained in the previous post, you can get and preserve on the client the access_token.

server.get("/oauth/github/login/callback", async (request, reply) => {
  const { code } = request.query;

  const exchangeURL = new URL("login/oauth/access_token", "https://github.com");
  exchangeURL.searchParams.set("client_id", process.env.CLIENT_ID);
  exchangeURL.searchParams.set("client_secret", process.env.CLIENT_SECRET);
  exchangeURL.searchParams.set("code", code);

  const response = await axios.post(exchangeURL.toString(), null, {
    headers: {
      Accept: "application/json",
    },
  });

  const { access_token } = response.data;

  const redirectionURL = new URL("new", "http://localhost:3000");
  redirectionURL.searchParams.set("access_token", access_token);

  reply.status(302).header("Location", redirectionURL).send();
});
Enter fullscreen mode Exit fullscreen mode

Still, if you try to query the two endpoints that return the app installations and their repositories, you'll get a 403.

Endpoints:
  1. /user/installations
  2. /user/installations/{installation_id}/repositories

We need another kind of access_token, one which is related to the GitHub App.

Therefore, create the GitHub App. This time you don't really need to assign any Callback URL (but you may want to set a Setup URL). For the purposes of this post, you are only interested in keeping track of the App ID (About section of your GitHub App configuration page) and generating and storing on your fs the Private Key (at the bottom of the page).


Generate a private key


Authenticate as GitHub App via JWT

As the official docs expain:

Authenticating as a GitHub App lets you do a couple of things [...] You can request access tokens for an installation of the app. To authenticate as a GitHub App, generate a private key in PEM format and download it to your local machine. You'll use this key to sign a JSON Web Token (JWT) and encode it using the RS256 algorithm. GitHub checks that the request is authenticated by verifying the token with the app's stored public key.

Thus, the server may have a /repos route which in turn generates the JWT:

const secret = fs.readFileSync(
  path.resolve(__dirname, ".private-key.pem"),
  "utf-8"
);

server.get("/repos", async (request, reply) => {
  const now = Math.floor(Date.now() / 1000) - 60; // don't just use Date.now()

  const payload = jwt.sign(
    {
      iat: now - 60,
      exp: now + 10 * 60,
      iss: process.env.APP_ID,
    },
    secret,
    {
      algorithm: "RS256",
    }
  );

  // ...
});
Enter fullscreen mode Exit fullscreen mode

And once the JWT is created, use it in the authentication header prefixed by Bearer (differently from the majority of GitHub REST API's endpoint, whom request token).


Get installation_ids of the GitHub App

The endpoint is /app/installations:

// still in /repos

const installations = await axios.get(
  `https://api/github.com/app/installations`,
  {
    headers: {
      Authorization: `Bearer ${payload}`,
    },
  }
);
Enter fullscreen mode Exit fullscreen mode

A list made of elements like to following is returned:

{
  id: 25061467,
  account: {
    login: '<some-username>',
    ...
  },
  repository_selection: 'selected',
  access_tokens_url: 'https://api.github.com/app/installations/25061467/access_tokens',
  repositories_url: 'https://api.github.com/installation/repositories',
},
Enter fullscreen mode Exit fullscreen mode

We need to filter in all items where .account.login equals the user's personal account or one of the user's organizations. You may retrieve this infos via the /user/org, keeping in mind to pass the access_token retrieved via the OAuth app.

Basically, something like:

const relevantInstallations = installations.data.filter((installation) => {
  return currentUserOrganizations.includes(installation.account.login);
});
Enter fullscreen mode Exit fullscreen mode

Get GitHub App's access_tokens

For each one of the installations relevant the our user (and all user's organizations), we request a GitHub App authenticated access_token via the endpoint /app/installations/{installation_id}/access_tokens:

const promises = relevantInstallations.map((installation) => {
    return axios.post(
      `https://api.github.com/app/installations/${installation_id}/access_tokens`,
      null,
      {
        headers: {
          Authorization: `Bearer ${payload}`,
        },
      }
    );
  });

// Parallel
const accessTokens = await axios.all(promises)
Enter fullscreen mode Exit fullscreen mode

Note: instead of manually assembling the url, you could use the premade installation.access_tokens_url.


Retrieve eligible repositories

Iterate each access_token and use it in the Authorization header token ${access_token} at the endpoint /installation/repositories (installation.repositories_url):

// Note: for each access_token!
const response = await axios.get(
  "https://api.github.com/installation/repositories",
  {
    headers: {
      Authorization: `token ${token}`, // not Bearer
    },
  }
);

const repositories = response.data.repositories;
Enter fullscreen mode Exit fullscreen mode

Merge or organize all the received repositories and you're back to the first post situation. We got there by a transverse, slightly more strenuous route - nonetheless, we're reached the goal.


Related Posts

Contacts

Top comments (0)