GitHub App and OAuth ~ Disjointed flow
Francesco Di Donato
Posted on April 28, 2022
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
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
- Create OAuth App and GitHub App
- Authenticate as GitHub App via JWT
- Get
installation_id
s of the GitHub App - Get GitHub App's
access_token
s - Retrieve eligible repositories
- Related Posts
Create OAuth App and GitHub App
Obviously it's necessary to create the OAuth App and also create the 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
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();
});
Still, if you try to query the two endpoints that return the app installations and their repositories, you'll get a 403.
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).
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",
}
);
// ...
});
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_id
s 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}`,
},
}
);
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',
},
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);
});
Get GitHub App's access_token
s
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)
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;
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
Posted on April 28, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.