Skip to content

feat: pluggable JWT signing for GitHub App auth (KMS/HSM support) - #117

Open
vegardx wants to merge 2 commits into
actions:mainfrom
vegardx:feat/pluggable-jwt-signing
Open

feat: pluggable JWT signing for GitHub App auth (KMS/HSM support)#117
vegardx wants to merge 2 commits into
actions:mainfrom
vegardx:feat/pluggable-jwt-signing

Conversation

@vegardx

@vegardx vegardx commented Jul 19, 2026

Copy link
Copy Markdown

What

Adds a JWTProvider interface 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.

// The new seam: anything that can produce a GitHub App JWT.
type JWTProvider interface {
	Token(ctx context.Context) (string, error)
}

Three ways in:

  • NewClientWithGitHubApp — unchanged public API. The PEM signing path just becomes an internal pemJWTProvider; same claims, same errors.
  • NewClientWithJWTProvider — new constructor accepting any JWTProvider plus the installation ID.
  • SignerJWTProvider — adapts any crypto.Signer whose Public() is *rsa.PublicKey. This is the KMS bridge: an awskms/cloudkms/keyvault signer plugs straight in.

Plus JWTProviderFunc, a function type implementing the interface (http.HandlerFunc style).

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.Signer without 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 SignerJWTProvider signs through the crypto.Signer

jwt/v5's SignedString requires a concrete *rsa.PrivateKey, so it cannot drive a remote signer — the very thing crypto.Signer exists to abstract. SignerJWTProvider therefore lets jwt/v5 build the token (SigningString() with RegisteredClaims: iss = Client ID, iat backdated 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 the Signer, encoding the signature with the library's own EncodeSegment. 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 with jwt.WithValidMethods pinning RS256.

One caveat worth stating: crypto.Signer.Sign takes no context.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 implement JWTProvider directly.

Commits

  1. build: upgrade golang-jwt/jwt v4 to v5 — prerequisite; v4 is in maintenance and v5 is where RegisteredClaims/parser options live. Minimal footprint: one require line swaps and go.sum carries only the v5 hashes. (go mod tidy is deliberately not run — 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; happy to file that separately.)
  2. feat: add JWTProvider interface for pluggable GitHub App auth — the feature as described above. actionsAuth holds jwtProvider + installationID instead of the GitHubAppAuth struct; validate() semantics, error messages, and the invalid credentials wrapping 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 ./... and go test -race ./... green across the repo, plus gofmt/golangci-lint/mockery-diff clean. New coverage in jwt_provider_test.go: a table runs the PEM and Signer providers through identical round-trip verification (RS256 pinned via jwt.WithValidMethods, claim timing asserted, context cancellation honored), signer error propagation, nil/missing-field validation, non-RSA signer rejection, invalid PEM, and JWTProviderFunc passthrough/error propagation. client_test.go adds an invalid-private-key case asserting the invalid credentials wrapping.

Compatibility

  • No exported API removed or changed; NewClientWithGitHubApp callers are unaffected.
  • GitHubAppAuth and its Validate() remain as-is.
  • Error messages and wrapping preserved so existing assertions/telemetry keyed on them keep working.

Closes #112

Copilot AI review requested due to automatic review settings July 19, 2026 10:12
@vegardx
vegardx requested review from a team and nikola-jokic as code owners July 19, 2026 10:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vegardx
vegardx force-pushed the feat/pluggable-jwt-signing branch from 947ab94 to 52eb4da Compare July 21, 2026 10:00
vegardx added 2 commits July 22, 2026 20:22
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.
@nikola-jokic

Copy link
Copy Markdown
Contributor

Hey @vegardx,

This PR looks very interesting! I'll get back to you when I discuss with the team if we should just use NewClient and add the TokenProvider as the interface. We can implement two default providers such as GitHubApp, and PersonalAccessToken providers, and allow users to bring their own providers such as vault or jwt signer. How does that sound to you?

@vegardx

vegardx commented Jul 23, 2026

Copy link
Copy Markdown
Author

Yes, that sounds like the direction of #121, which is stacked on this PR. It introduces a TokenProvider for final bearer tokens and supports externally minted/cached GitHub App installation tokens.

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 TokenProvider implementations behind a common NewClient.

We could structure the GitHub App provider so it uses the JWTProvider/crypto.Signer seam from this PR internally, retaining the KMS/HSM use case. Is that the shape you have in mind?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support pluggable JWT signing for GitHub App auth (KMS/HSM)

3 participants