Skip to content

feat: TokenProvider for externally minted bearer tokens - #121

Open
vegardx wants to merge 3 commits into
actions:mainfrom
vegardx:feat/token-provider
Open

feat: TokenProvider for externally minted bearer tokens#121
vegardx wants to merge 3 commits into
actions:mainfrom
vegardx:feat/token-provider

Conversation

@vegardx

@vegardx vegardx commented Jul 22, 2026

Copy link
Copy Markdown

What

Generalizes PAT auth into a TokenProvider interface consulted on every re-authentication, so callers using short-lived credentials, GitHub App installation tokens in particular, can rotate them without rebuilding the client.

// The new seam: anything that can produce a bearer token.
type TokenProvider interface {
	Token(ctx context.Context) (string, error)
}

Two ways in:

  • NewClientWithPersonalAccessToken: unchanged public API; now the degenerate case of a provider returning a constant.
  • NewClientWithTokenProvider: new constructor accepting any TokenProvider. Plus TokenProviderFunc, a function adapter (http.HandlerFunc style).

The token is sent verbatim as Authorization: Bearer for a single request; the client neither inspects nor caches it. A provider handing out expiring credentials therefore owns their renewal, which is the point of the seam: renewal can then be shared across clients and processes rather than duplicated inside each one.

The doc comment spells out which credential types are valid at which config-URL scope:

  • Enterprise (https://HOST/enterprises/NAME) requires a PAT with manage_runners:enterprise. Today a GitHub App is installed on an organization or a repository, never on an enterprise, so no installation token can be minted at this scope. Nothing in the client enforces that: if GitHub ships enterprise-installable Apps, their installation tokens flow through this seam unchanged.
  • Organization: a PAT with admin:org, or an installation token from an App holding organization_self_hosted_runners.
  • Repository: a PAT with repo, or an installation token from an App holding administration.

Why

At fleet scale, installation-token minting is rate limited and worth coordinating across processes: a shared cache, a single-flight mint, credential selection by remaining quota. A client that mints its own token cannot participate in that coordination; one that asks for a token can. Today the bearer is a fixed string captured at construction, so short-lived credentials force client rebuilds.

This is not speculative: we run this in production today against GitHub Enterprise Cloud with data residency, with installation tokens minted and renewed outside the client and handed in through this seam.

Testing

go build ./... and go test -race ./... green across the repo. New coverage in token_provider_test.go: nil-provider rejection, the provider being consulted on every re-authentication (not cached), provider-error propagation, and TokenProviderFunc passthrough.

Compatibility

  • No exported symbol changes signature or is removed; NewClientWithPersonalAccessToken is untouched.
  • Credential validation becomes exactly-one-of {PAT, token provider, App credentials}, but the pre-existing App+PAT conflict keeps its original error wording, so callers matching on that string are unaffected.

Stacked on #117 (pluggable JWT signing): shares its exactly-one-of validation with the jwtProvider field introduced there. Only the last commit (feat(client): TokenProvider for externally minted bearer tokens) is new to review here; this rebases to a single commit once #117 lands.

vegardx added 3 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.
NewClientWithPersonalAccessToken takes the bearer as a fixed string captured
at construction. This generalizes that to a TokenProvider consulted on every
re-authentication, so callers using short-lived credentials -- GitHub App
installation tokens in particular -- can rotate them without rebuilding the
client.

Motivation: at fleet scale, installation-token minting is rate limited and
worth coordinating across processes, via a shared cache, a single-flight
mint, and credential selection by remaining quota. A client that mints its
own token cannot participate in that coordination; one that asks for a token
can.

Backwards compatible: no exported symbol changes signature or is removed,
NewClientWithPersonalAccessToken is untouched and is now the degenerate case
of a provider returning a constant, and the pre-existing App-plus-PAT
conflict keeps its original error wording so callers matching on that string
are unaffected. The existing test suite passes unmodified.

Stacked on the pluggable-JWT-signing branch, which introduces the jwtProvider
field this shares its exactly-one-of validation with.
@vegardx
vegardx requested a review from nikola-jokic as a code owner July 22, 2026 19:16
Copilot AI review requested due to automatic review settings July 22, 2026 19:16
@vegardx
vegardx requested a review from a team as a code owner July 22, 2026 19:16

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.

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.

2 participants