GitHub App e OAuth ~ Flusso disgiunto

GitHub App e OAuth ~ Flusso disgiunto

Scritto da Francesco Di Donato 28 aprile 2022 4 minuti di lettura

In un post precedente, abbiamo visto come sia possibile ottenere informazioni dall’API REST di GitHub riguardo a quali repository hanno la nostra App GitHub installata. Questo ci consente di costruire qualcosa di simile al seguente componente.

Componente Import Git Repository di Vercel

Componente Import Git Repository di Vercel 🔗

Poiché questi dati sono legati a un’App GitHub, il token necessario è emesso dall’OAuth incluso nell’App GitHub in questione.

Q: Il mio sistema è già basato sull’autenticazione tramite un’App OAuth, non posso modificarlo! Inoltre, è gestito da un terzo (i.e. Stytch 🔗). Tuttavia, il access_token emesso da un OAuth non è valido per i due endpoint!

A: C’è una soluzione. È leggermente più costosa rispetto a quella precedente, ma funziona.

Indice

  1. Crea App OAuth e App GitHub
  2. Autenticati come App GitHub tramite JWT
  3. Ottieni gli installation_id dell’App GitHub
  4. Ottieni gli access_token dell’App GitHub
  5. Recupera i repository idonei
  6. Post correlati

Crea App OAuth e App GitHub

Ovviamente è necessario creare l’App OAuth 🔗 e anche creare l’App GitHub 🔗.

Schermata di creazione dell'App OAuth di GitHub

  • URL della homepage: http://localhost:3000 🔗
  • URL di callback: Dove il provider dovrebbe riportare l’utente una volta che il flusso di autenticazione è completato. Puoi scegliere qualsiasi percorso, sto usando /oauth/github/login/callback

Finalize la creazione. Le è stato assegnato un Client ID ed è possibile generare un Client Secret. Fai questo e tienili a portata di mano.

Simile a quanto spiegato nel post precedente 🔗, puoi ottenere e preservare sul client il 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();
});

Tuttavia, se provi a interrogare i due endpoint che restituiscono le installazioni dell’app e i loro repository, otterrai un 403.

Endpoints

  1. /user/installations 🔗
  2. /user/installations/:installation_id/repositories 🔗

Abbiamo bisogno di un altro tipo di access_token, uno che sia relativo all’App GitHub.

Quindi, crea l’App GitHub. Questa volta non è davvero necessario assegnare alcun URL di callback (ma potresti voler impostare un URL di configurazione). Ai fini di questo post, sei interessato solo a tenere traccia dell’App ID (sezione Informazioni della tua pagina di configurazione dell’App GitHub) e a generare e memorizzare sul tuo fs la Chiave Privata (in fondo alla pagina).

Genera una chiave privata


Autenticati come App GitHub tramite JWT

Come spiega la documentazione ufficiale 🔗:

Autenticarsi come App GitHub ti consente di fare alcune cose […] Puoi richiedere token di accesso per un’installazione dell’app. Per autenticarti come App GitHub, genera una chiave privata in formato PEM e scaricala sul tuo computer. Utilizzerai questa chiave per firmare un JSON Web Token (JWT 🔗) e codificarlo utilizzando l’algoritmo RS256. GitHub controlla che la richiesta sia autenticata verificando il token con la chiave pubblica memorizzata dell’app.

Quindi, il server potrebbe avere un percorso /repos che di conseguenza genera il 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; // non usare solo Date.now()

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

  // ...
});

E una volta creato il JWT, usalo nell’intestazione di autenticazione preceduto da Bearer (diversamente dalla maggior parte degli endpoint dell’API REST di GitHub, che richiedono token ).


Ottieni gli installation_id dell’App GitHub

L’endpoint è /app/installations 🔗:

// ancora in /repos

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

Viene restituita una lista composta da elementi come il seguente:

{
  "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",
},

Abbiamo bisogno di filtrare in tutti gli elementi dove .account.login è uguale all’account personale dell’utente o a uno delle organizzazioni dell’utente. Puoi recuperare queste informazioni tramite /user/org 🔗, tenendo presente di passare l’access_token recuperato tramite l’app OAuth.

Fondamentalmente, qualcosa di simile:

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

Ottieni gli access_token dell’App GitHub

Per ciascuna delle installazioni rilevanti per il nostro utente (e tutte le organizzazioni dell’utente), richiediamo un access_token autenticato dell’App GitHub tramite l’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)

Nota: invece di assemblare manualmente l’url, potresti utilizzare l’installation.access_tokens_url già predisposta.


Recupera i repository idonei

Itera ciascun access_token e usalo nell’intestazione di autorizzazione token ${access_token} all’endpoint /installation/repositories (installation.repositories_url):

// Nota: per ogni access_token!
const response = await axios.get(
  "https://api.github.com/installation/repositories",
  {
    headers: {
      Authorization: `token ${token}`, // non Bearer
    },
  }
);

const repositories = response.data.repositories;

Unisci o organizza tutti i repository ricevuti e sei tornato alla situazione del primo post. Ci siamo arrivati attraverso un percorso trasversale, leggermente più faticoso - tuttavia, abbiamo raggiunto l’obiettivo.