From abd6ede8444a89b6c56c20d06110cb3923b05bbe Mon Sep 17 00:00:00 2001 From: Francois Kritzinger Date: Tue, 6 Feb 2024 16:35:59 +0200 Subject: Get installation access token (IAT) and restructure code --- mod/jwt.cxx | 189 +++++++++++ mod/jwt.hxx | 37 +++ mod/mod-ci-github.cxx | 863 ++++++++++++++++++++++++++++++++++---------------- mod/mod-ci-github.hxx | 116 ++++++- mod/module.cli | 31 +- 5 files changed, 949 insertions(+), 287 deletions(-) create mode 100644 mod/jwt.cxx create mode 100644 mod/jwt.hxx (limited to 'mod') 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 + +#include +#include +#include + +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 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 ( + system_clock::now ().time_since_epoch () - bd)); + + // Expiration time. + // + seconds exp (iat + vp); + + vector 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 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 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 +#include + +#include + +#include + +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 +#include #include +#include #include -#include +#include +#include // @@ 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 -// -// 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": "" -// } -// -// 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//access_tokens -// which includes the JWT (`Authorization: Bearer `). 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 ( - 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 ( + 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 << "" << endl << cs << endl; + + installation_access_token iat ( + obtain_installation_access_token (cs.installation.id, generate_jwt ())); + + cout << endl << "" << 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 + 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 // - 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//access_tokens which + // includes the JWT (`Authorization: Bearer `). 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 << "" << 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 (); + 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 (); - 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 (); + 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 << "" << endl << cs.check_suite; + os << "" << endl << cs.repository; + os << "" << 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 << "" << endl << cs.check_suite; - os << "" << 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 - #include #include #include #include +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_; + shared_ptr 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 + { + "", + "The GitHub App ID. Found in the app's settings on GitHub." + } + + path ci-github-app-private-key + { + "", + "The private key used during GitHub API authentication. Created in + the GitHub app's settings." + } + + uint16_t ci-github-jwt-validity-period = 600 + { + "", + "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 -- cgit v1.1