aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFrancois Kritzinger <francois@codesynthesis.com>2024-02-06 16:35:59 +0200
committerFrancois Kritzinger <francois@codesynthesis.com>2024-06-05 09:12:45 +0200
commita376770e0a8ed304660d911dfc4448aeb8c814b0 (patch)
tree1d29153a38480d514cb716d53e104502edd703d5
parent976d5d4c0c8b8b9eeaf2f386e72e2db06e83ac41 (diff)
Generate JWT
-rw-r--r--mod/jwt.cxx147
-rw-r--r--mod/jwt.hxx35
-rw-r--r--mod/mod-ci-github.cxx78
-rw-r--r--mod/mod-ci-github.hxx4
-rw-r--r--mod/module.cli20
5 files changed, 231 insertions, 53 deletions
diff --git a/mod/jwt.cxx b/mod/jwt.cxx
new file mode 100644
index 0000000..11b76ec
--- /dev/null
+++ b/mod/jwt.cxx
@@ -0,0 +1,147 @@
+#include <mod/jwt.hxx>
+
+#include <libbutl/base64.hxx>
+#include <libbutl/openssl.hxx>
+#include <libbutl/timestamp.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::gen_jwt (const options::openssl_options& o,
+ const path& pk,
+ const string& iss,
+ const std::chrono::minutes& vp)
+{
+ // Create the header.
+ //
+ string h; // Header (base64url-encoded).
+ {
+ vector<char> b;
+ json::buffer_serializer s (b);
+
+ 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.
+ //
+ // @@ TODO GitHub recommends setting this time to 60 seconds in the past
+ // to combat clock drift. Seems likely to be a general problem
+ // with client/server authentication schemes so perhaps passing
+ // the expected drift/skew as an argument might make sense?
+ //
+ seconds iat (
+ duration_cast<seconds> (system_clock::now ().time_since_epoch ()));
+
+ // Expiration time.
+ //
+ seconds exp (iat + vp);
+
+ vector<char> b;
+ json::buffer_serializer s (b);
+
+ 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.
+ //
+
+ // The signature (base64url-encoded). Will be left empty if openssl exits
+ // with a non-zero status.
+ //
+ string s;
+ {
+ // 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.
+ //
+ openssl os (path ("-"), // Read message from openssl::out.
+ path ("-"), // Write output to openssl::in.
+ 2, // Diagnostics to stderr.
+ process_env (o.openssl (), o.openssl_envvar ()),
+ "dgst", o.openssl_option (), "-sha256", "-sign", pk);
+
+ // Write the concatenated header and payload to openssl's input.
+ //
+ os.out << h << '.' << p;
+ os.out.close ();
+
+ // Read the binary signature from openssl's output.
+ //
+ vector<char> bs (os.in.read_binary ());
+ os.in.close ();
+
+ if (os.wait ())
+ s = base64url_encode (bs);
+ }
+
+ // Return the token, or empty if openssl exited with a non-zero status.
+ //
+ return !s.empty ()
+ ? h + '.' + p + '.' + s
+ : "";
+}
diff --git a/mod/jwt.hxx b/mod/jwt.hxx
new file mode 100644
index 0000000..65ad5c5
--- /dev/null
+++ b/mod/jwt.hxx
@@ -0,0 +1,35 @@
+#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 RFC 7519.
+ //
+ // 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.
+ //
+ // Return the token or empty if openssl exited with a non-zero status.
+ //
+ // Throw process_error or io_error (both derived from std::system_error) if
+ // openssl could not be executed or communication with its process failed.
+ //
+ string
+ gen_jwt (const options::openssl_options&,
+ const path& private_key,
+ const string& issuer,
+ const std::chrono::minutes& validity_period);
+}
+
+#endif
diff --git a/mod/mod-ci-github.cxx b/mod/mod-ci-github.cxx
index 0a28b63..a82ce43 100644
--- a/mod/mod-ci-github.cxx
+++ b/mod/mod-ci-github.cxx
@@ -5,8 +5,10 @@
#include <libbutl/json/parser.hxx>
+#include <mod/jwt.hxx>
#include <mod/module-options.hxx>
+#include <stdexcept>
#include <iostream>
// @@ TODO
@@ -62,55 +64,6 @@
//
// - 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
//
@@ -135,7 +88,7 @@ brep::ci_github::ci_github (const ci_github& r)
void brep::ci_github::
init (scanner& s)
{
- options_ = make_shared<options::ci> (
+ options_ = make_shared<options::ci_github> (
s, unknown_mode::fail, unknown_mode::fail);
}
@@ -278,6 +231,31 @@ handle (request& rq, response& rs)
cout << "<check_suite webhook>" << endl << cs << endl;
+ try
+ {
+ // Use the maximum validity period allowed by GitHub (10 minutes).
+ //
+ string jwt (gen_jwt (*options_,
+ options_->ci_github_app_private_key (),
+ to_string (options_->ci_github_app_id ()),
+ chrono::minutes (10)));
+
+ if (jwt.empty ())
+ fail << "unable to generate JWT: " << options_->openssl ()
+ << " failed";
+
+ cout << "JWT: " << jwt << endl;
+ }
+ catch (const system_error& e)
+ {
+ fail << "unable to generate JWT: unable to execute "
+ << options_->openssl () << ": " << e.what ();
+ }
+ catch (const std::exception& e)
+ {
+ fail << "unable to generate JWT: " << e;
+ }
+
return true;
}
else if (event == "pull_request")
diff --git a/mod/mod-ci-github.hxx b/mod/mod-ci-github.hxx
index a869878..72bbf82 100644
--- a/mod/mod-ci-github.hxx
+++ b/mod/mod-ci-github.hxx
@@ -29,14 +29,14 @@ 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&);
private:
- shared_ptr<options::ci> options_;
+ shared_ptr<options::ci_github> options_;
};
}
diff --git a/mod/module.cli b/mod/module.cli
index 5133935..1e266e4 100644
--- a/mod/module.cli
+++ b/mod/module.cli
@@ -815,11 +815,29 @@ namespace brep
{
};
- class ci_github: ci_start, build, build_db, handler
+ // @@ TODO Is etc/brep-module.conf updated manually?
+ //
+ 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).
//
+
+ 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."
+ }
};
class upload: build, build_db, build_upload, repository_email, handler