feat: pluggable JWT signing for GitHub App auth (KMS/HSM support) - #117
feat: pluggable JWT signing for GitHub App auth (KMS/HSM support)#117vegardx wants to merge 2 commits into
Conversation
947ab94 to
52eb4da
Compare
v4 is in maintenance; v5 is where RegisteredClaims and parser options live. Minimal footprint: the one require line swaps and go.sum carries only the v5 hashes. (go mod tidy is not run here — it fails on main today from the pre-existing monolithic-vs-split genproto ambiguity in the docker example's test closure, unrelated to this change.)
Adds a JWTProvider interface so GitHub App JWTs can be signed outside the client — enabling KMS-backed keys (AWS KMS, GCP Cloud KMS, Azure Key Vault, HSMs) where private key material never leaves the secure boundary. - NewClientWithGitHubApp keeps its exact public API; the PEM path becomes an internal pemJWTProvider and parse failures wrap in the same 'invalid credentials' contract. - NewClientWithJWTProvider is the new entry point for custom signers. - SignerJWTProvider adapts any crypto.Signer whose Public() is *rsa.PublicKey. The token is assembled by jwt/v5 (SigningString) and the digest signed through the Signer, because SignedString requires a concrete *rsa.PrivateKey and cannot drive a remote signer. - JWTProviderFunc is a function type implementing JWTProvider (http.HandlerFunc style). - Both providers share one claims builder and check ctx cancellation before signing. - actionsAuth.validate() reworded for the provider field; error messages and the invalid-credentials wrapping are unchanged.
52eb4da to
37c52df
Compare
|
Hey @vegardx, This PR looks very interesting! I'll get back to you when I discuss with the team if we should just use |
|
Yes, that sounds like the direction of #121, which is stacked on this PR. It introduces a The main difference is that #121 currently preserves the existing constructors and keeps GitHub App auth as a separate internal path. Your suggestion sounds like taking that one step further: making PAT and GitHub App auth built-in We could structure the GitHub App provider so it uses the |
What
Adds a
JWTProviderinterface so GitHub App JWTs can be signed outside the client, enabling KMS-backed App keys (AWS KMS, GCP Cloud KMS, Azure Key Vault, PKCS#11 HSMs) where the private key material never leaves the secure boundary. Existing constructors and behavior are unchanged.Three ways in:
NewClientWithGitHubApp— unchanged public API. The PEM signing path just becomes an internalpemJWTProvider; same claims, same errors.NewClientWithJWTProvider— new constructor accepting anyJWTProviderplus the installation ID.SignerJWTProvider— adapts anycrypto.SignerwhosePublic()is*rsa.PublicKey. This is the KMS bridge: anawskms/cloudkms/keyvaultsigner plugs straight in.Plus
JWTProviderFunc, a function type implementing the interface (http.HandlerFuncstyle).Why
GitHub App private keys are long-lived, org-wide credentials. Today the client requires the PEM in memory, which forces operators to distribute and rotate raw key material through their config/secret plumbing. Cloud KMS services expose RSA keys as
crypto.Signerwithout ever releasing the key — but the client has no seam to accept one.We run this in production against GitHub Enterprise Cloud with data residency (GHEC DR) with the App key held in AWS KMS: the client signs App JWTs via
SignerJWTProvider, and the raw key exists nowhere in our infrastructure.Why
SignerJWTProvidersigns through thecrypto.Signerjwt/v5'sSignedStringrequires a concrete*rsa.PrivateKey, so it cannot drive a remote signer — the very thingcrypto.Signerexists to abstract.SignerJWTProvidertherefore lets jwt/v5 build the token (SigningString()withRegisteredClaims:iss= Client ID,iatbackdated 1m,exp= +9m, matching the existing PEM path and GitHub's 10-minute cap), then SHA-256s the signing input and signs the digest through theSigner, encoding the signature with the library's ownEncodeSegment. Both providers share one claims builder, so the PEM and Signer paths cannot drift. Output is a standard RS256 JWT; the tests run both providers through identical round-trip verification withjwt.WithValidMethodspinning RS256.One caveat worth stating:
crypto.Signer.Signtakes nocontext.Context, so cancellation is checked before signing but cannot propagate into the signing call itself. Implementations needing that (e.g. per-request KMS timeouts) can wrap their signer or implementJWTProviderdirectly.Commits
RegisteredClaims/parser options live. Minimal footprint: onerequireline swaps andgo.sumcarries only the v5 hashes. (go mod tidyis deliberately not run — it fails onmaintoday from the pre-existing monolithic-vs-split genproto ambiguity in the docker example's test closure, unrelated to this change; happy to file that separately.)actionsAuthholdsjwtProvider+installationIDinstead of theGitHubAppAuthstruct;validate()semantics, error messages, and theinvalid credentialswrapping are preserved, and the new validation tests from Add basic validation on credentials when instantiating clients #102 are adapted to the provider field.(Currently stacked on the runtime-generated-test-certs PR so CI can run green —
main's committed certs expired 2026-07-13; rebases to just the two commits above once that lands.)Testing
go build ./...andgo test -race ./...green across the repo, plus gofmt/golangci-lint/mockery-diff clean. New coverage injwt_provider_test.go: a table runs the PEM and Signer providers through identical round-trip verification (RS256 pinned viajwt.WithValidMethods, claim timing asserted, context cancellation honored), signer error propagation, nil/missing-field validation, non-RSA signer rejection, invalid PEM, andJWTProviderFuncpassthrough/error propagation.client_test.goadds an invalid-private-key case asserting theinvalid credentialswrapping.Compatibility
NewClientWithGitHubAppcallers are unaffected.GitHubAppAuthand itsValidate()remain as-is.Closes #112