GitHub App and OAuth ~ Practical Kick-Starter
Francesco Di Donato
Posted on April 26, 2022
What can we build
An interface that gives a GitHub-authenticated user (or any user's organization) the ability to see which of his repositories have a given GitHub App installed.
Vercel's Import Git Repository component
Premise
I found two different ways to achieve the goal.
- Standalone provider (GitHub App)
- Disjointed flow (OAuth App, GitHub App).
If you know of other modalities, please contact me (twitter).
In this post we are implementing the first modality.
Index
Create GitHub App
In order for us to find the list of repos that have the GitHub App installed, we must first create one.
For the purposes of this post, we just need to know that to authenticate a user through GitHub, you need to register your own OAuth app; however, every GitHub App has an OAuth inside it.
That's why I (arbitrarily) call this method standalone - we only use a single GitHub App.
- 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
- Setup URL: Where the provider should send back the user once the GitHub has been installed/uninstalled/permission-changed. You can pick any route, I'm using /new.
Configure the app so that it has the right to read the content. Otherwise, it won't be able to be installed on specific repos.
You'll also find a Webhook section. For the purposes of this post we don't care, so to continue you can just mark it as inactive.
Finally create the GitHub App. It has been assigned an App ID, a Client ID and it's possible to generate a Client Secret - All the stuff we need, but in a little while.
GitHub OAuth
Authenticating a user with GitHub is straight forward.
If something isn't clear kindly refer to companion repo.
First, the frontend presents a CTA which points to a route on the server.
<a id="oauth-github">Authenticate via GitHub</a>
<script>
const CLIENT_ID = "Iv1.395930440f268143";
const url = new URL("/login/oauth/authorize", "https://github.com");
url.searchParams.set("client_id", CLIENT_ID);
document.getElementById("oauth-github")
.setAttribute("href", url);
</script>
To remain concise, I'm only reporting Note: security
client_id
since it's required; however in a production context be sure to review the official documentation, especially regarding scope
and state
.
Make it pretty, add the GitHub icon to it - the user presses it and is taken to the following page:
Now, when the green Authorize button is pressed, the user is redirected to Callback URL set during GitHub App creation.
Let's make sure that the server is able to listen to /oauth/github/login/callback
. Watch out for this key step: GitHub redirects and adds a query param, a code
needed to authenticate the user.
server.get("/oauth/github/login/callback", async (request, reply) => {
const { code } = request.query;
// ...
});
code
is supposed to be exchanged with an access_token
, which will be stored in the client and associated with requests to the GitHub REST API.
Now, before moving on, please go back to your GitHub App Configuration page and Generate a new Client Secret. Thus, dotenv
it.
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;
For the sake of brevity, the example does not report error handling. It is left to the common sense of the reader.
Thus, the token is delivered to the client whom ultimately is redirected to some /new
route. But - here's the hero of our story - also receives the access_token
as query param.
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();
Extract it on the client:
<!-- new.html, sent when GET /new -->
<script>
const access_token = new URL(window.location).searchParams.get(
"access_token"
);
localStorage.setItem("access_token", access_token);
</script>
You can store it any Web Storage, in-memory, really depends on how your interface is to be used. Although an HttpOnly and Secure cookie would help mitigate XSS attacks.
The client of the authenticated user can finally associate the access_token
when it legitimately queries the GitHub REST API. Almost there.
Usually other OAuth in the wild do not hard redirect the current page. Suppose to be working on a SPA, think how sad it is to find out that this flow empties your in-memory store. Easy to solve, the redirection will still happen but in a spawned popup (post) that, before dissolving will pass the Enhancement: popup instead of redirection
access_token
back to main tab.
Query the GitHub REST API
The data we need is returned from two endpoints.
The latter is called with information received from the former.
The documentation states:
You must use a user-to-server OAuth access token, created for a user who has authorized your GitHub App, to access this endpoint.
The access_token
returned by a "simple" OAuth App is not valid for these endpoints. However, there's a way to make it work using an OAuth and three other different endpoints. This is shown in the second post.
const githubAPI = axios.create({
baseURL: "https://api.github.com",
headers: {
Accept: "application/vnd.github.v3+json",
},
});
const authorizationConf = {
headers: {
authorization: `token ${access_token}`,
},
};
(async () => {
const installationResponse = await githubAPI.get(
"user/installations",
authorizationConf
);
})();
Setting to
application/vnd.github.v3+json
is recommended by the official docs. It's the GitHub API custom media type.
We receive back a list containing... wait, it's empty. That's because the GitHub App has not yet been installed anywhere.
Let's make it easy to install:
<a
href="https://github.com/apps/<github-app-name>/installations/new"
>
Change permissions
</a>
That once is clicked shows the following screen:
Either pick you personal account or one of your organizations. Or both. Install it somewhere.
Now /user/installations
returns a list of installations. One for each account (personal account || organization) that has at least one repo with the github app installed in it.
Each item has the property id
. That's the installation_id
required for the next endpoint.
const promises = installationsResponse.data.installations.map(
(installation) => {
return githubAPI.get(
`user/installations/${installation.id}/repositories`,
authorizationConf
);
}
);
// parallel
const responses = await axios.all(promises);
const repositories = responses.map((response) => {
return response.data.repositories;
});
And there you have all the repositories
of the authenticated user or one of his organizations that have the GitHub App installed.
Epilogue
To recap, you now can:
- Authenticate users via GitHub OAuth authentication
- Redirect users to install your GitHub App
- Show them a dropdown containing all the organization in addition the the personal account
- Know which repositories to show
I'll leave it up to you all the fun of implementing the UI with your favorite framework. If you need to look at the full tour, in the companion repo I used technologies that anyone knows.
Related Posts
Contacts
Posted on April 26, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.