aboutsummaryrefslogtreecommitdiff
path: root/mod
diff options
context:
space:
mode:
authorFrancois Kritzinger <francois@codesynthesis.com>2024-02-06 16:35:59 +0200
committerFrancois Kritzinger <francois@codesynthesis.com>2024-12-10 11:16:07 +0200
commitabd6ede8444a89b6c56c20d06110cb3923b05bbe (patch)
tree48600db8ab80599e014c07ad79769469754321c0 /mod
parent1c994eadb89bdafdbeb7e16adcf4f0a55c497942 (diff)
Get installation access token (IAT) and restructure code
Diffstat (limited to 'mod')
-rw-r--r--mod/jwt.cxx189
-rw-r--r--mod/jwt.hxx37
-rw-r--r--mod/mod-ci-github.cxx863
-rw-r--r--mod/mod-ci-github.hxx116
-rw-r--r--mod/module.cli31
5 files changed, 949 insertions, 287 deletions
diff --git a/mod/jwt.cxx b/mod/jwt.cxx
new file mode 100644
index 0000000..4e28630
--- /dev/null
+++ b/mod/jwt.cxx
@@ -0,0 +1,189 @@
+#include <mod/jwt.hxx>
+
+#include <libbutl/base64.hxx>
+#include <libbutl/openssl.hxx>
+#include <libbutl/json/serializer.hxx>
+
+using namespace std;
+using namespace butl;
+
+// Note that only GitHub's requirements are implemented, not the entire JWT
+// spec. The following elements are currently supported:
+//
+// - The RS256 message authentication code algorithm (RSA with SHA256)
+// - The `typ` and `alg` header fields
+// - The `iat`, `exp`, and `iss` claims
+//
+// A JWT consists of a message and its signature.
+//
+// The message consists of a base64url-encoded JSON header and payload (set of
+// claims). The signature is calculated over the message and then also
+// base64url-encoded.
+//
+// base64url is base64 with a slightly different alphabet and optional padding
+// to make it URL and filesystem safe. See base64.hxx for details.
+//
+// Header:
+//
+// {
+// "typ": "JWT",
+// "alg": "RS256"
+// }
+//
+// Payload:
+//
+// {
+// "iat": 1234567,
+// "exp": 1234577,
+// "iss": "MyName"
+// }
+//
+// Where:
+// iat := Issued At (NumericDate: seconds since 1970-01-01T00:00:00Z UTC)
+// exp := Expiration Time (NumericDate)
+// iss := Issuer
+//
+// Signature:
+//
+// RSA_SHA256(PKEY, base64url($header) + '.' + base64url($payload))
+//
+// JWT:
+//
+// base64url($header) + '.' + base64url($payload) + '.' + base64url($signature)
+//
+string brep::
+generate_jwt (const options::openssl_options& o,
+ const path& pk,
+ const string& iss,
+ const chrono::seconds& vp,
+ const chrono::seconds& bd)
+{
+ // Create the header.
+ //
+ string h; // Header (base64url-encoded).
+ {
+ vector<char> b;
+ json::buffer_serializer s (b, 0 /* indentation */);
+
+ s.begin_object ();
+ s.member ("typ", "JWT");
+ s.member ("alg", "RS256"); // RSA with SHA256.
+ s.end_object ();
+
+ h = base64url_encode (b);
+ }
+
+ // Create the payload.
+ //
+ string p; // Payload (base64url-encoded).
+ {
+ using namespace std::chrono;
+
+ // "Issued at" time.
+ //
+ seconds iat (duration_cast<seconds> (
+ system_clock::now ().time_since_epoch () - bd));
+
+ // Expiration time.
+ //
+ seconds exp (iat + vp);
+
+ vector<char> b;
+ json::buffer_serializer s (b, 0 /* indentation */);
+
+ s.begin_object ();
+ s.member ("iss", iss);
+ s.member ("iat", iat.count ());
+ s.member ("exp", exp.count ());
+ s.end_object ();
+
+ p = base64url_encode (b);
+ }
+
+ // Create the signature.
+ //
+ string s; // Signature (base64url-encoded).
+ try
+ {
+ // Sign the concatenated header and payload using openssl.
+ //
+ // openssl dgst -sha256 -sign <pkey> file...
+ //
+ // Note that RSA is indicated by the contents of the private key.
+ //
+ // Note that here we assume both output and diagnostics will fit into pipe
+ // buffers and don't poll both with fdselect().
+ //
+ fdpipe errp (fdopen_pipe ()); // stderr pipe.
+
+ openssl os (path ("-"), // Read message from openssl::out.
+ path ("-"), // Write output to openssl::in.
+ process::pipe (errp.in.get (), move (errp.out)),
+ process_env (o.openssl (), o.openssl_envvar ()),
+ "dgst", o.openssl_option (), "-sha256", "-sign", pk);
+
+ ifdstream err (move (errp.in));
+
+ vector<char> bs; // Binary signature (openssl output).
+ try
+ {
+ // In case of exception, skip and close input after output.
+ //
+ // Note: re-open in/out so that they get automatically closed on
+ // exception.
+ //
+ ifdstream in (os.in.release (), fdstream_mode::skip);
+ ofdstream out (os.out.release ());
+
+ // Write the concatenated header and payload to openssl's input.
+ //
+ out << h << '.' << p;
+ out.close ();
+
+ // Read the binary signature from openssl's output.
+ //
+ bs = in.read_binary ();
+ in.close ();
+ }
+ catch (const io_error& e)
+ {
+ // If the process exits with non-zero status, assume the IO error is due
+ // to that and fall through.
+ //
+ if (os.wait ())
+ {
+ throw_generic_error (
+ e.code ().value (),
+ (string ("unable to read/write openssl stdout/stdin: ") +
+ e.what ()).c_str ());
+ }
+ }
+
+ if (!os.wait ())
+ {
+ string et (err.read_text ());
+ throw_generic_error (EINVAL,
+ ("non-zero openssl exit status: " + et).c_str ());
+ }
+
+ err.close ();
+
+ s = base64url_encode (bs);
+ }
+ catch (const process_error& e)
+ {
+ throw_generic_error (
+ e.code ().value (),
+ (string ("unable to execute openssl: ") + e.what ()).c_str ());
+ }
+ catch (const io_error& e)
+ {
+ // Unable to read diagnostics from stderr.
+ //
+ throw_generic_error (
+ e.code ().value (),
+ (string ("unable to read openssl stderr : ") + e.what ()).c_str ());
+ }
+
+ return h + '.' + p + '.' + s; // Return the token.
+}
diff --git a/mod/jwt.hxx b/mod/jwt.hxx
new file mode 100644
index 0000000..b0df714
--- /dev/null
+++ b/mod/jwt.hxx
@@ -0,0 +1,37 @@
+#ifndef MOD_JWT_HXX
+#define MOD_JWT_HXX
+
+#include <libbrep/types.hxx>
+#include <libbrep/utility.hxx>
+
+#include <mod/module-options.hxx>
+
+#include <chrono>
+
+namespace brep
+{
+ // Generate a JSON Web Token (JWT), defined in RFC7519.
+ //
+ // A JWT is essentially the token issuer's name along with a number of
+ // claims, signed with a private key.
+ //
+ // Note that only GitHub's requirements are implemented, not the entire JWT
+ // spec; see the source file for details.
+ //
+ // The token expires when the validity period has elapsed.
+ //
+ // The backdate argument specifies the number of seconds to subtract from
+ // the "issued at" time in order to combat potential clock drift (which can
+ // cause the token to be not valid yet).
+ //
+ // Return the token or throw std::system_error in case of an error.
+ //
+ string
+ generate_jwt (const options::openssl_options&,
+ const path& private_key,
+ const string& issuer,
+ const std::chrono::seconds& validity_period,
+ const std::chrono::seconds& backdate = std::chrono::seconds (60));
+}
+
+#endif
diff --git a/mod/mod-ci-github.cxx b/mod/mod-ci-github.cxx
index 0a28b63..e19a41b 100644
--- a/mod/mod-ci-github.cxx
+++ b/mod/mod-ci-github.cxx
@@ -3,11 +3,14 @@
#include <mod/mod-ci-github.hxx>
+#include <libbutl/curl.hxx>
#include <libbutl/json/parser.hxx>
+#include <mod/jwt.hxx>
#include <mod/module-options.hxx>
-#include <iostream>
+#include <stdexcept>
+#include <iostream> // @@ TODO Remove once debug output has been removed.
// @@ TODO
//
@@ -19,6 +22,7 @@
//
// Webhooks:
// https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks
+// https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
//
// REST API:
// https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api?apiVersion=2022-11-28
@@ -30,363 +34,662 @@
// https://en.wikipedia.org/wiki/HMAC#Definition. A suitable implementation
// is provided by OpenSSL.
-// @@ Authenticating to use the API
-//
-// There are three types of authentication:
-//
-// 1) Authenticating as an app. Used to access parts of the API concerning
-// the app itself such as getting the list of installations. (Need to
-// authenticate as an app as part of authenticating as an app
-// installation.)
-//
-// 2) Authenticating as an app installation (on a user or organisation
-// account). Used to access resources belonging to the user/repository
-// or organisation the app is installed in.
-//
-// 3) Authenticating as a user. Used to perform actions as the user.
-//
-// We need to authenticate as an app installation (2).
-//
-// How to authenticate as an app installation
-//
-// Reference:
-// https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
-//
-// The final authentication token we need is an installation access token
-// (IAT), valid for one hour, which we will pass in the `Authentication`
-// header of our Github API requests:
-//
-// Authorization: Bearer <INSTALLATION_ACCESS_TOKEN>
-//
-// To generate an IAT:
-//
-// - Generate a JSON Web Token (JWT)
-//
-// The inputs are (one of) the application's private key(s) and the
-// application ID, which goes into the "issuer" JWT field. Also the
-// token's issued time and expiration time (10 minutes max).
-//
-// The best library for us seems to be libjwt at
-// https://github.com/benmcollins/libjwt which is also widely packaged
-// (most Linux distros and Homebrew).
-//
-// Doing it ourselves:
-//
-// Github requires the RS256 algorithm, which is RSA signing using
-// SHA256.
-//
-// The message consists of a base64url-encoded JSON header and
-// payload. (base64url differs from base64 by having different 62nd and
-// 63rd alphabet characters (- and _ instead of ~ and . to make it
-// filesystem-safe) and no padding because the padding character '=' is
-// unsafe in URIs.)
-//
-// Header:
-//
-// {
-// "alg": "RS256",
-// "typ": "JWT"
-// }
-//
-// Payload:
-//
-// {
-// "iat": 1234567,
-// "exp": 1234577,
-// "iss": "<APP_ID>"
-// }
-//
-// Where:
-// iat := Issued At (NumericDate)
-// exp := Expires At (NumericDate)
-// iss := Issuer
-//
-// Signature:
-//
-// RSA_SHA256(PKEY, base64url($header) + "." + base64url($payload))
-//
-// JWT:
-//
-// base64url($header) + "." +
-// base64url($payload) + "." +
-// base64url($signature)
-//
-// - Get the installation ID. This will be included in the webhook request
-// in our case
-//
-// - Send a POST to /app/installations/<INSTALLATION_ID>/access_tokens
-// which includes the JWT (`Authorization: Bearer <JWT>`). The response
-// will include the IAT. Can pass the name of the repository included in
-// the webhook request to restrict access, otherwise we get access to all
-// repos covered by the installation if installed on an organisation for
-// example.
-
using namespace std;
using namespace butl;
using namespace web;
using namespace brep::cli;
-brep::ci_github::ci_github (const ci_github& r)
- : handler (r),
- options_ (r.initialized_ ? r.options_ : nullptr)
+namespace brep
{
-}
+ using namespace gh;
-void brep::ci_github::
-init (scanner& s)
-{
- options_ = make_shared<options::ci> (
- s, unknown_mode::fail, unknown_mode::fail);
-}
+ ci_github::
+ ci_github (const ci_github& r)
+ : handler (r),
+ options_ (r.initialized_ ? r.options_ : nullptr)
+ {
+ }
-// The "check_suite" object within a check_quite webhook request.
-//
-struct check_suite
-{
- uint64_t id;
- string head_branch;
- string head_sha;
- string before;
- string after;
+ void ci_github::
+ init (scanner& s)
+ {
+ options_ = make_shared<options::ci_github> (
+ s, unknown_mode::fail, unknown_mode::fail);
+ }
- explicit
- check_suite (json::parser&);
+ bool ci_github::
+ handle (request& rq, response&)
+ {
+ using namespace bpkg;
- check_suite () = default;
-};
+ HANDLER_DIAG;
-struct repository
-{
- string name;
- string full_name;
- string default_branch;
+ // @@ TODO: disable service if HMAC is not specified in config.
+ //
+ if (false)
+ throw invalid_request (404, "CI request submission disabled");
- explicit repository (json::parser&);
+ // Process headers.
+ //
+ // @@ TMP Shouldn't we also error<< in some of these header problem cases?
+ //
+ // @@ TMP From GitHub docs: "You can create webhooks that subscribe to the
+ // events listed on this page."
+ //
+ // So it seems appropriate to generally use the term "event" (which
+ // we already do for the most part), and "webhook event" only when
+ // more context would be useful?
+ //
+ string event; // Webhook event.
+ {
+ bool content_type (false);
- repository () = default;
-};
+ for (const name_value& h: rq.headers ())
+ {
+ // This event's UUID.
+ //
+ if (icasecmp (h.name, "x-github-delivery") == 0)
+ {
+ // @@ TODO Check that delivery UUID has not been received before
+ // (replay attack).
+ }
+ else if (icasecmp (h.name, "content-type") == 0)
+ {
+ if (!h.value)
+ throw invalid_request (400, "missing content-type value");
-struct check_suite_event
-{
- string action;
- ::check_suite check_suite;
- ::repository repository;
+ if (icasecmp (*h.value, "application/json") != 0)
+ {
+ throw invalid_request (400,
+ "invalid content-type value: '" + *h.value +
+ '\'');
+ }
- explicit check_suite_event (json::parser&);
+ content_type = true;
+ }
+ // The webhook event.
+ //
+ else if (icasecmp (h.name, "x-github-event") == 0)
+ {
+ if (!h.value)
+ throw invalid_request (400, "missing x-github-event value");
- check_suite_event () = default;
-};
+ event = *h.value;
+ }
+ }
-static ostream&
-operator<< (ostream&, const check_suite&);
+ if (!content_type)
+ throw invalid_request (400, "missing content-type header");
-static ostream&
-operator<< (ostream&, const repository&);
+ if (event.empty ())
+ throw invalid_request (400, "missing x-github-event header");
+ }
-static ostream&
-operator<< (ostream&, const check_suite_event&);
+ // There is a webhook event (specified in the x-github-event header) and
+ // each event contains a bunch of actions (specified in the JSON request
+ // body).
+ //
+ // Note: "GitHub continues to add new event types and new actions to
+ // existing event types." As a result we ignore known actions that we are
+ // not interested in and log and ignore unknown actions. The thinking here
+ // is that we want be "notified" of new actions at which point we can decide
+ // whether to ignore them or to handle.
+ //
+ if (event == "check_suite")
+ {
+ check_suite_event cs;
+ try
+ {
+ json::parser p (rq.content (64 * 1024), "check_suite event");
-bool brep::ci_github::
-handle (request& rq, response& rs)
-{
- using namespace bpkg;
+ cs = check_suite_event (p);
+ }
+ catch (const json::invalid_json_input& e)
+ {
+ string m ("malformed JSON in " + e.name + " request body");
- HANDLER_DIAG;
+ error << m << ", line: " << e.line << ", column: " << e.column
+ << ", byte offset: " << e.position << ", error: " << e;
- // @@ TODO
- if (false)
- throw invalid_request (404, "CI request submission disabled");
+ throw invalid_request (400, move (m));
+ }
- // Process headers.
+ if (cs.action == "requested")
+ {
+ return handle_check_suite_request (move (cs));
+ }
+ else if (cs.action == "rerequested")
+ {
+ // Someone manually requested to re-run the check runs in this check
+ // suite. Treat as a new request.
+ //
+ return handle_check_suite_request (move (cs));
+ }
+ else if (cs.action == "completed")
+ {
+ // GitHub thinks that "all the check runs in this check suite have
+ // completed and a conclusion is available". Looks like this one we
+ // ignore?
+ //
+ // @@ TODO What if our bookkeeping says otherwise? See conclusion
+ // field which includes timedout. Need to come back to this once
+ // have the "happy path" implemented.
+ //
+ return true;
+ }
+ else
+ {
+ // Ignore unknown actions by sending a 200 response with empty body
+ // but also log as an error since we want to notice new actions.
+ //
+ error << "unknown action '" << cs.action << "' in check_suite event";
+
+ return true;
+ }
+ }
+ else if (event == "pull_request")
+ {
+ // @@ TODO
+
+ throw invalid_request (501, "pull request events not implemented yet");
+ }
+ else
+ {
+ // Log to investigate.
+ //
+ error << "unexpected event '" << event << "'";
+
+ throw invalid_request (400, "unexpected event: '" + event + "'");
+ }
+ }
+
+ bool ci_github::
+ handle_check_suite_request (check_suite_event cs) const
+ {
+ cout << "<check_suite event>" << endl << cs << endl;
+
+ installation_access_token iat (
+ obtain_installation_access_token (cs.installation.id, generate_jwt ()));
+
+ cout << endl << "<installation_access_token>" << endl << iat << endl;
+
+ return true;
+ }
+
+ // Send a POST request to the GitHub API endpoint `ep`, parse GitHub's JSON
+ // response into `rs` (only for 200 codes), and return the HTTP status code.
+ //
+ // The endpoint `ep` should not have a leading slash.
+ //
+ // Pass additional HTTP headers in `hdrs`. For example:
+ //
+ // "HeaderName: header value"
+ //
+ // Throw invalid_argument if unable to parse the response headers,
+ // invalid_json_input (derived from invalid_argument) if unable to parse the
+ // response body, and system_error in other cases.
//
- string event;
+ template<typename T>
+ static uint16_t
+ github_post (T& rs, const string& ep, const strings& hdrs)
{
- bool content_type (false);
+ // Convert the header values to curl header option/value pairs.
+ //
+ strings hdr_opts;
- for (const name_value& h: rq.headers ())
+ for (const string& h: hdrs)
{
- if (icasecmp (h.name, "x-github-delivery") == 0)
- {
- // @@ TODO Check that delivery UUID has not been received before
- // (replay attack).
- }
- else if (icasecmp (h.name, "content-type") == 0)
+ hdr_opts.push_back ("--header");
+ hdr_opts.push_back (h);
+ }
+
+ // Run curl.
+ //
+ try
+ {
+ // Pass --include to print the HTTP status line (followed by the response
+ // headers) so that we can get the response status code.
+ //
+ // Suppress the --fail option which causes curl to exit with status 22
+ // in case of an error HTTP response status code (>= 400) otherwise we
+ // can't get the status code.
+ //
+ // Note that butl::curl also adds --location to make curl follow redirects
+ // (which is recommended by GitHub).
+ //
+ // The API version `2022-11-28` is the only one currently supported. If
+ // the X-GitHub-Api-Version header is not passed this version will be
+ // chosen by default.
+ //
+ fdpipe errp (fdopen_pipe ()); // stderr pipe.
+
+ curl c (nullfd,
+ path ("-"), // Write response to curl::in.
+ process::pipe (errp.in.get (), move (errp.out)),
+ curl::post,
+ curl::flags::no_fail,
+ "https://api.github.com/" + ep,
+ "--no-fail", // Don't fail if response status code >= 400.
+ "--include", // Output response headers for status code.
+ "--header", "Accept: application/vnd.github+json",
+ "--header", "X-GitHub-Api-Version: 2022-11-28",
+ move (hdr_opts));
+
+ ifdstream err (move (errp.in));
+
+ // Parse the HTTP response.
+ //
+ int sc; // Status code.
+ try
{
- if (!h.value)
- throw invalid_request (400, "missing content-type value");
+ // Note: re-open in/out so that they get automatically closed on
+ // exception.
+ //
+ ifdstream in (c.in.release (), fdstream_mode::skip);
- if (icasecmp (*h.value, "application/json") != 0)
+ sc = curl::read_http_status (in).code; // May throw invalid_argument.
+
+ // Parse the response body if the status code is in the 200 range.
+ //
+ if (sc >= 200 && sc < 300)
{
- throw invalid_request (400,
- "invalid content-type value: '" + *h.value +
- '\'');
+ // Use endpoint name as input name (useful to have it propagated
+ // in exceptions).
+ //
+ json::parser p (in, ep /* name */);
+ rs = T (p);
}
- content_type = true;
+ in.close ();
+ }
+ catch (const io_error& e)
+ {
+ // If the process exits with non-zero status, assume the IO error is due
+ // to that and fall through.
+ //
+ if (c.wait ())
+ {
+ throw_generic_error (
+ e.code ().value (),
+ (string ("unable to read curl stdout: ") + e.what ()).c_str ());
+ }
}
- else if (icasecmp (h.name, "x-github-event") == 0)
+ catch (const json::invalid_json_input&)
{
- if (!h.value)
- throw invalid_request (400, "missing x-github-event value");
+ // If the process exits with non-zero status, assume the JSON error is
+ // due to that and fall through.
+ //
+ if (c.wait ())
+ throw;
+ }
- event = *h.value;
+ if (!c.wait ())
+ {
+ string et (err.read_text ());
+ throw_generic_error (EINVAL,
+ ("non-zero curl exit status: " + et).c_str ());
}
+
+ err.close ();
+
+ return sc;
+ }
+ catch (const process_error& e)
+ {
+ throw_generic_error (
+ e.code ().value (),
+ (string ("unable to execute curl:") + e.what ()).c_str ());
+ }
+ catch (const io_error& e)
+ {
+ // Unable to read diagnostics from stderr.
+ //
+ throw_generic_error (
+ e.code ().value (),
+ (string ("unable to read curl stderr : ") + e.what ()).c_str ());
}
+ }
- if (!content_type)
- throw invalid_request (400, "missing content-type header");
+ string ci_github::
+ generate_jwt () const
+ {
+ string jwt;
+ try
+ {
+ // Set token's "issued at" time 60 seconds in the past to combat clock
+ // drift (as recommended by GitHub).
+ //
+ jwt = brep::generate_jwt (
+ *options_,
+ options_->ci_github_app_private_key (),
+ to_string (options_->ci_github_app_id ()),
+ chrono::seconds (options_->ci_github_jwt_validity_period ()),
+ chrono::seconds (60));
+
+ cout << "JWT: " << jwt << endl;
+ }
+ catch (const system_error& e)
+ {
+ HANDLER_DIAG;
- if (event.empty ())
- throw invalid_request (400, "missing x-github-event header");
+ fail << "unable to generate JWT (errno=" << e.code () << "): " << e;
+ }
+
+ return jwt;
}
- // There is an event (specified in the x-github-event header) and each event
- // contains a bunch of actions (specified in the JSON request body).
+ // There are three types of GitHub API authentication:
+ //
+ // 1) Authenticating as an app. Used to access parts of the API concerning
+ // the app itself such as getting the list of installations. (Need to
+ // authenticate as an app as part of authenticating as an app
+ // installation.)
+ //
+ // 2) Authenticating as an app installation (on a user or organisation
+ // account). Used to access resources belonging to the user/repository
+ // or organisation the app is installed in.
+ //
+ // 3) Authenticating as a user. Used to perform actions as the user.
+ //
+ // We need to authenticate as an app installation (2).
+ //
+ // How to authenticate as an app installation
+ //
+ // Reference:
+ // https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
+ //
+ // The final authentication token we need is an installation access token
+ // (IAT), valid for one hour, which we will pass in the `Authentication`
+ // header of our Github API requests:
//
- // Note: "GitHub continues to add new event types and new actions to
- // existing event types." As a result we ignore known actions that we are
- // not interested in and log and ignore unknown actions. The thinking here
- // is that we want be "notified" of new actions at which point we can decide
- // whether to ignore them or to handle.
+ // Authorization: Bearer <INSTALLATION_ACCESS_TOKEN>
//
- try
+ // To generate an IAT:
+ //
+ // - Generate a JSON Web Token (JWT)
+ //
+ // - Get the installation ID. This will be included in the webhook request
+ // in our case
+ //
+ // - Send a POST to /app/installations/<INSTALLATION_ID>/access_tokens which
+ // includes the JWT (`Authorization: Bearer <JWT>`). The response will
+ // include the IAT. Can pass the name of the repository included in the
+ // webhook request to restrict access, otherwise we get access to all
+ // repos covered by the installation if installed on an organisation for
+ // example.
+ //
+ installation_access_token ci_github::
+ obtain_installation_access_token (uint64_t iid, string jwt) const
{
- if (event == "check_suite")
+ HANDLER_DIAG;
+
+ installation_access_token iat;
+ try
{
- json::parser p (rq.content (64 * 1024), "check_suite webhook");
+ // API endpoint.
+ //
+ string ep ("app/installations/" + to_string (iid) + "/access_tokens");
- check_suite_event cs (p);
+ int sc (github_post (iat, ep, strings {"Authorization: Bearer " + jwt}));
- // @@ TODO: log and ignore unknown.
+ // Possible response status codes from the access_tokens endpoint:
//
- if (cs.action == "requested")
- {
- }
- else if (cs.action == "rerequested")
- {
- // Someone manually requested to re-run the check runs in this check
- // suite.
- }
- else if (cs.action == "completed")
+ // 201 Created
+ // 401 Requires authentication
+ // 403 Forbidden
+ // 404 Resource not found
+ // 422 Validation failed, or the endpoint has been spammed.
+ //
+ // Note that the payloads of non-201 status codes are undocumented.
+ //
+ if (sc != 201)
{
- // GitHub thinks that "all the check runs in this check suite have
- // completed and a conclusion is available". Looks like this one we
- // ignore?
+ fail << "unable to get installation access token: "
+ << "error HTTP response status " << sc;
}
- else
- throw invalid_request (400, "unsupported action: " + cs.action);
-
- cout << "<check_suite webhook>" << endl << cs << endl;
-
- return true;
}
- else if (event == "pull_request")
+ catch (const json::invalid_json_input& e)
{
- throw invalid_request (501, "pull request events not implemented yet");
+ // Note: e.name is the GitHub API endpoint.
+ //
+ fail << "malformed JSON in response from " << e.name << ", line: "
+ << e.line << ", column: " << e.column << ", byte offset: "
+ << e.position << ", error: " << e;
}
- else
- throw invalid_request (400, "unexpected event: '" + event + "'");
+ catch (const invalid_argument& e)
+ {
+ fail << "malformed header(s) in response: " << e;
+ }
+ catch (const system_error& e)
+ {
+ fail << "unable to get installation access token (errno=" << e.code ()
+ << "): " << e.what ();
+ }
+
+ return iat;
}
- catch (const json::invalid_json_input& e)
+
+ // The rest is GitHub request/response type parsing and printing.
+ //
+ using event = json::event;
+
+ // Throw invalid_json_input when a required member `m` is missing from a
+ // JSON object `o`.
+ //
+ [[noreturn]] static void
+ missing_member (const json::parser& p, const char* o, const char* m)
{
- // @@ TODO: should we write more detailed diagnostics to log? Maybe we
- // should do this for all unsuccessful calls to respond().
- //
- // @@ TMP These exceptions end up in the apache error log.
+ throw json::invalid_json_input (
+ p.input_name,
+ p.line (), p.column (), p.position (),
+ o + string (" object is missing member '") + m + '\'');
+ }
+
+ // check_suite
+ //
+ gh::check_suite::
+ check_suite (json::parser& p)
+ {
+ p.next_expect (event::begin_object);
+
+ bool i (false), hb (false), hs (false), bf (false), at (false);
+
+ // Skip unknown/uninteresting members.
//
- throw invalid_request (400, "malformed JSON in request body");
+ while (p.next_expect (event::name, event::end_object))
+ {
+ auto c = [&p] (bool& v, const char* s)
+ {
+ return p.name () == s ? (v = true) : false;
+ };
+
+ if (c (i, "id")) id = p.next_expect_number<uint64_t> ();
+ else if (c (hb, "head_branch")) head_branch = p.next_expect_string ();
+ else if (c (hs, "head_sha")) head_sha = p.next_expect_string ();
+ else if (c (bf, "before")) before = p.next_expect_string ();
+ else if (c (at, "after")) after = p.next_expect_string ();
+ else p.next_expect_value_skip ();
+ }
+
+ if (!i) missing_member (p, "check_suite", "id");
+ if (!hb) missing_member (p, "check_suite", "head_branch");
+ if (!hs) missing_member (p, "check_suite", "head_sha");
+ if (!bf) missing_member (p, "check_suite", "before");
+ if (!at) missing_member (p, "check_suite", "after");
}
-}
-using event = json::event;
+ ostream&
+ gh::operator<< (ostream& os, const check_suite& cs)
+ {
+ os << "id: " << cs.id << endl
+ << "head_branch: " << cs.head_branch << endl
+ << "head_sha: " << cs.head_sha << endl
+ << "before: " << cs.before << endl
+ << "after: " << cs.after << endl;
-// check_suite
-//
-check_suite::check_suite (json::parser& p)
-{
- p.next_expect (event::begin_object);
+ return os;
+ }
- // Skip unknown/uninteresting members.
+ // repository
//
- while (p.next_expect (event::name, event::end_object))
+ gh::repository::
+ repository (json::parser& p)
{
- const string& n (p.name ());
-
- if (n == "id") id = p.next_expect_number<uint64_t> ();
- else if (n == "head_branch") head_branch = p.next_expect_string ();
- else if (n == "head_sha") head_sha = p.next_expect_string ();
- else if (n == "before") before = p.next_expect_string ();
- else if (n == "after") after = p.next_expect_string ();
- else p.next_expect_value_skip ();
- }
-}
+ p.next_expect (event::begin_object);
-static ostream&
-operator<< (ostream& os, const check_suite& cs)
-{
- os << "id: " << cs.id << endl
- << "head_branch: " << cs.head_branch << endl
- << "head_sha: " << cs.head_sha << endl
- << "before: " << cs.before << endl
- << "after: " << cs.after << endl;
+ bool nm (false), fn (false), db (false);
- return os;
-}
+ // Skip unknown/uninteresting members.
+ //
+ while (p.next_expect (event::name, event::end_object))
+ {
+ auto c = [&p] (bool& v, const char* s)
+ {
+ return p.name () == s ? (v = true) : false;
+ };
-// repository
-//
-repository::repository (json::parser& p)
-{
- p.next_expect (event::begin_object);
+ if (c (nm, "name")) name = p.next_expect_string ();
+ else if (c (fn, "full_name")) full_name = p.next_expect_string ();
+ else if (c (db, "default_branch")) default_branch = p.next_expect_string ();
+ else p.next_expect_value_skip ();
+ }
+
+ if (!nm) missing_member (p, "repository", "name");
+ if (!fn) missing_member (p, "repository", "full_name");
+ if (!db) missing_member (p, "repository", "default_branch");
+ }
+
+ ostream&
+ gh::operator<< (ostream& os, const repository& rep)
+ {
+ os << "name: " << rep.name << endl
+ << "full_name: " << rep.full_name << endl
+ << "default_branch: " << rep.default_branch << endl;
+
+ return os;
+ }
- // Skip unknown/uninteresting members.
+ // installation
//
- while (p.next_expect (event::name, event::end_object))
+ gh::installation::
+ installation (json::parser& p)
{
- const string& n (p.name ());
+ p.next_expect (event::begin_object);
+
+ bool i (false);
+
+ // Skip unknown/uninteresting members.
+ //
+ while (p.next_expect (event::name, event::end_object))
+ {
+ auto c = [&p] (bool& v, const char* s)
+ {
+ return p.name () == s ? (v = true) : false;
+ };
+
+ if (c (i, "id")) id = p.next_expect_number<uint64_t> ();
+ else p.next_expect_value_skip ();
+ }
- if (n == "name") name = p.next_expect_string ();
- else if (n == "full_name") full_name = p.next_expect_string ();
- else if (n == "default_branch") default_branch = p.next_expect_string ();
- else p.next_expect_value_skip ();
+ if (!i) missing_member (p, "installation", "id");
}
-}
-static ostream&
-operator<< (ostream& os, const repository& rep)
-{
- os << "name: " << rep.name << endl
- << "full_name: " << rep.full_name << endl
- << "default_branch: " << rep.default_branch << endl;
+ ostream&
+ gh::operator<< (ostream& os, const installation& i)
+ {
+ os << "id: " << i.id << endl;
- return os;
-}
+ return os;
+ }
-// check_suite_event
-//
-check_suite_event::check_suite_event (json::parser& p)
-{
- p.next_expect (event::begin_object);
+ // check_suite_event
+ //
+ gh::check_suite_event::
+ check_suite_event (json::parser& p)
+ {
+ p.next_expect (event::begin_object);
+
+ bool ac (false), cs (false), rp (false), in (false);
+
+ // Skip unknown/uninteresting members.
+ //
+ while (p.next_expect (event::name, event::end_object))
+ {
+ auto c = [&p] (bool& v, const char* s)
+ {
+ return p.name () == s ? (v = true) : false;
+ };
+
+ if (c (ac, "action")) action = p.next_expect_string ();
+ else if (c (cs, "check_suite")) check_suite = gh::check_suite (p);
+ else if (c (rp, "repository")) repository = gh::repository (p);
+ else if (c (in, "installation")) installation = gh::installation (p);
+ else p.next_expect_value_skip ();
+ }
+
+ if (!ac) missing_member (p, "check_suite_event", "action");
+ if (!cs) missing_member (p, "check_suite_event", "check_suite");
+ if (!rp) missing_member (p, "check_suite_event", "repository");
+ if (!in) missing_member (p, "check_suite_event", "installation");
+ }
+
+ ostream&
+ gh::operator<< (ostream& os, const check_suite_event& cs)
+ {
+ os << "action: " << cs.action << endl;
+ os << "<check_suite>" << endl << cs.check_suite;
+ os << "<repository>" << endl << cs.repository;
+ os << "<installation>" << endl << cs.installation;
+
+ return os;
+ }
- // Skip unknown/uninteresting members.
+ // installation_access_token
+ //
+ // Example JSON:
//
- while (p.next_expect (event::name, event::end_object))
+ // {
+ // "token": "ghs_Py7TPcsmsITeVCAWeVtD8RQs8eSos71O5Nzp",
+ // "expires_at": "2024-02-15T16:16:38Z",
+ // ...
+ // }
+ //
+ gh::installation_access_token::
+ installation_access_token (json::parser& p)
{
- const string& n (p.name ());
+ p.next_expect (event::begin_object);
+
+ bool tk (false), ea (false);
+
+ // Skip unknown/uninteresting members.
+ //
+ while (p.next_expect (event::name, event::end_object))
+ {
+ auto c = [&p] (bool& v, const char* s)
+ {
+ return p.name () == s ? (v = true) : false;
+ };
- if (n == "action") action = p.next_expect_string ();
- else if (n == "check_suite") check_suite = ::check_suite (p);
- else if (n == "repository") repository = ::repository (p);
- else p.next_expect_value_skip ();
+ if (c (tk, "token")) token = p.next_expect_string ();
+ else if (c (ea, "expires_at"))
+ {
+ const string& s (p.next_expect_string ());
+ expires_at = from_string (s.c_str (), "%Y-%m-%dT%TZ", false /* local */);
+ }
+ else p.next_expect_value_skip ();
+ }
+
+ if (!tk) missing_member (p, "installation_access_token", "token");
+ if (!ea) missing_member (p, "installation_access_token", "expires_at");
}
-}
-static ostream&
-operator<< (ostream& os, const check_suite_event& cs)
-{
- os << "action: " << cs.action << endl;
- os << "<check_suite>" << endl << cs.check_suite;
- os << "<repository>" << endl << cs.repository << endl;
+ ostream&
+ gh::operator<< (ostream& os, const installation_access_token& t)
+ {
+ os << "token: " << t.token << endl;
+ os << "expires_at: ";
+ butl::operator<< (os, t.expires_at) << endl;
- return os;
+ return os;
+ }
}
diff --git a/mod/mod-ci-github.hxx b/mod/mod-ci-github.hxx
index a869878..9731881 100644
--- a/mod/mod-ci-github.hxx
+++ b/mod/mod-ci-github.hxx
@@ -4,16 +4,111 @@
#ifndef MOD_MOD_CI_GITHUB_HXX
#define MOD_MOD_CI_GITHUB_HXX
-#include <web/xhtml/fragment.hxx>
-
#include <libbrep/types.hxx>
#include <libbrep/utility.hxx>
#include <mod/module.hxx>
#include <mod/module-options.hxx>
+namespace butl
+{
+ namespace json
+ {
+ class parser;
+ }
+}
+
namespace brep
{
+ // GitHub request/response types.
+ //
+ // Note that having this types directly in brep causes clashes (e.g., for
+ // the repository name).
+ //
+ namespace gh
+ {
+ namespace json = butl::json;
+
+ // The "check_suite" object within a check_suite webhook event request.
+ //
+ struct check_suite
+ {
+ uint64_t id;
+ string head_branch;
+ string head_sha;
+ string before;
+ string after;
+
+ explicit
+ check_suite (json::parser&);
+
+ check_suite () = default;
+ };
+
+ struct repository
+ {
+ string name;
+ string full_name;
+ string default_branch;
+
+ explicit
+ repository (json::parser&);
+
+ repository () = default;
+ };
+
+ struct installation
+ {
+ uint64_t id;
+
+ explicit
+ installation (json::parser&);
+
+ installation () = default;
+ };
+
+ // The check_suite webhook event request.
+ //
+ struct check_suite_event
+ {
+ string action;
+ gh::check_suite check_suite;
+ gh::repository repository;
+ gh::installation installation;
+
+ explicit
+ check_suite_event (json::parser&);
+
+ check_suite_event () = default;
+ };
+
+ struct installation_access_token
+ {
+ string token;
+ timestamp expires_at;
+
+ explicit
+ installation_access_token (json::parser&);
+
+ installation_access_token () = default;
+ };
+
+ ostream&
+ operator<< (ostream&, const check_suite&);
+
+ ostream&
+ operator<< (ostream&, const repository&);
+
+ ostream&
+ operator<< (ostream&, const installation&);
+
+ ostream&
+ operator<< (ostream&, const check_suite_event&);
+
+ ostream&
+ operator<< (ostream&, const installation_access_token&);
+ }
+
class ci_github: public handler
{
public:
@@ -29,14 +124,27 @@ namespace brep
handle (request&, response&);
virtual const cli::options&
- cli_options () const {return options::ci::description ();}
+ cli_options () const {return options::ci_github::description ();}
private:
virtual void
init (cli::scanner&);
+ // Handle the check_suite event `requested` and `rerequested` actions.
+ //
+ bool
+ handle_check_suite_request (gh::check_suite_event) const;
+
+ string
+ generate_jwt () const;
+
+ // Authenticate to GitHub as an app installation.
+ //
+ gh::installation_access_token
+ obtain_installation_access_token (uint64_t install_id, string jwt) const;
+
private:
- shared_ptr<options::ci> options_;
+ shared_ptr<options::ci_github> options_;
};
}
diff --git a/mod/module.cli b/mod/module.cli
index ccfe032..7c07dbc 100644
--- a/mod/module.cli
+++ b/mod/module.cli
@@ -845,11 +845,36 @@ namespace brep
{
};
- class ci_github: ci_start, build, build_db, handler
+ // @@ TODO Is etc/brep-module.conf updated manually? Yes, will need to
+ // replicate there eventually.
+ //
+ class ci_github: ci_start, ci_cancel,
+ build, build_db,
+ handler,
+ openssl_options
{
- // GitHub CI-specific options (e.g., request timeout when invoking
- // GitHub APIs).
+ // GitHub CI-specific options.
//
+
+ size_t ci-github-app-id
+ {
+ "<id>",
+ "The GitHub App ID. Found in the app's settings on GitHub."
+ }
+
+ path ci-github-app-private-key
+ {
+ "<path>",
+ "The private key used during GitHub API authentication. Created in
+ the GitHub app's settings."
+ }
+
+ uint16_t ci-github-jwt-validity-period = 600
+ {
+ "<seconds>",
+ "The number of seconds a JWT (authentication token) should be valid for.
+ The maximum allowed by GitHub is 10 minutes."
+ }
};
class upload: build, build_db, build_upload, repository_email, handler