Expand description
cratefield-adapter-github-app: GitHub App authentication (issue #623).
A GitHub App authenticates in two steps. The app proves who it is with a
short-lived RS256 JWT signed by its private key (iss = the App ID,
iat/exp minutes apart); it exchanges that JWT for an installation
token (ghs_…) scoped to one installation, its permissions and its
repositories. A venture that acts for a user adds a third: the
user-to-server OAuth code exchange (ghu_…), and
GithubApp::user_can_access_installation is the check it runs before
linking an installation to an account.
Everything goes through the runtime’s HttpClient and Clock ports —
no vendor SDK, no reqwest, no tokio, no std::time — so the same
client runs on Cloudflare Workers and natively (ADR 0002: the ports are
core’s, the vendor client is this crate’s). Signing reuses
cratefield-push-auth’s Rs256Signer and its keyed CachedToken,
because it is the same construction the push adapters already ship.
Nothing here logs a secret. The Debug impls redact the key and every
token, the error messages carry no key material, and the only tracing
calls emit metadata (the installation id, the status) and never a token.
§cratefield-adapter-github-app
GitHub App authentication for the Cratefield harness (issue #623). It turns
an App’s private key into the credentials the GitHub REST API accepts, over
the runtime’s HttpClient and Clock ports — no vendor SDK, no reqwest,
no tokio, no std::time — so the same client runs on Cloudflare Workers
and natively (ADR 0002: the ports are core’s, the vendor client is this
crate’s). Signing reuses cratefield-push-auth’s Rs256Signer and its keyed
CachedToken, the same verified stack the push adapters present.
§Usage
use std::sync::Arc;
use cratefield_adapter_github_app::{GithubApp, DEFAULT_API_BASE};
use cratefield_runtime_cloudflare::{FetchClient, WorkersClock};
let app = GithubApp::new(
app_id,
Some(private_key_pem), // the App's .pem; None => NotConfigured
Arc::new(FetchClient),
Arc::new(WorkersClock),
DEFAULT_API_BASE,
);
// An installation token, cached and single-flighted per installation+scope.
let token = app.installation_token(installation_id, None, None).await?;
let response = app
.request(installation_id, http::Request::get("/repos/acme/widgets/issues").body(bytes::Bytes::new())?)
.await?;§The three credentials
| What | Where it comes from | Lifetime |
|---|---|---|
| App JWT | app_jwt(): RS256 over {iss: <app id>, iat: now-60, exp: now+540} | 9 minutes, minted per call |
Installation token (ghs_…) | installation_token(): the JWT exchanged at POST /app/installations/{id}/access_tokens | 1 hour; cached 55 minutes |
User token (ghu_…) | exchange_user_code(): the user-to-server OAuth code traded at /login/oauth/access_token | as GitHub states |
iss is the App ID as a decimal string, which is what GitHub’s docs
show for the client id and what the numeric app id renders to.
§Requests, caching and single flight
request() authenticates with an installation token and sets Accept,
X-GitHub-Api-Version: 2022-11-28 and a User-Agent — GitHub rejects a
request without one — unless the caller already set them. Pass your own
Authorization and no installation token is minted at all.
The request’s URI may be absolute or relative; a relative one is resolved
against the client’s API base, so a Link URL (absolute) is followed as-is.
Installation tokens are cached in a CachedToken keyed by
(installation_id, scope), where the scope is the requested permissions and
repositories in canonical order — a token scoped to one repository is never
handed to a call scoped to another. An absent scope and a present-but-empty
one get different entries: Some(&empty) sends "permissions": {} (“grant
nothing”) where None omits the field (“narrow nothing”). Concurrent cold
calls for one scope make one HTTP exchange for the whole client: one
app-wide async mutex is held across check-cache → exchange → store, so a
second caller arriving mid-flight waits and then reads the token the first
stored. A token minted already inside the five-minute safety margin is
returned but not cached. A 401 on a request this client authenticated drops
the cached token, mints a fresh one and replays exactly once; a second
401 is returned to the caller.
GithubResponse carries the response and GitHub’s rate-limit headers
(X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After). Non-2xx
statuses are returned rather than classified, so the caller decides; 304 Not Modified is the normal outcome of a conditional GET sent with with_etag,
and GithubResponse::not_modified() recognises it.
§Pagination
next_page(headers) reads the rel="next" URL out of a Link header
(several comma-separated links, quoted rel, commas inside a URL left
alone), and paginate(installation_id, first_url, max_pages) follows it up
to the cap.
A next link is followed only when it names the same origin — scheme, host
and port — as the API base. The next page is fetched with the installation
token in Authorization, and a Link header is the one part of a response
an upstream gets to choose, so a cross-origin link ends the walk instead.
user_can_access_installation applies the same rule and answers false
when it meets one: the call exists to grant access, so an unverifiable walk
fails closed.
request (and so paginate’s first URL) holds the same line for every
request it authenticates: an absolute URL on another origin is refused with
GithubAppError::ForeignOrigin before a token is minted. A caller that sets
its own Authorization is not minted a token, so the check does not apply.
§User-to-server
exchange_user_code(client_id, client_secret, code, redirect_uri) posts to
{web_base}/login/oauth/access_token (GitHub’s web host, not the API
host — with_web_base overrides it for GitHub Enterprise Server). GitHub
answers 200 with {"error": …} on failure, so the body decides: the error
becomes GithubAppError::OAuth carrying GitHub’s error code only, never the
body. A non-2xx without an error field is a plain GithubAppError::Status
carrying the status and the rate-limit headers, whether or not the body is
JSON — a gateway’s HTML page is not a decode failure.
user_can_access_installation(user_token, installation_id) walks
GET /user/installations?per_page=100 (following Link pages, capped) and
reports whether the user authorised that installation — the check to run
before linking an installation to an account.
Both take their credential from the caller: the code exchange uses the client id and secret, and the access check uses the user’s own token, so neither needs the app’s private key.
§Nothing leaks
The Debug impls of GithubApp, InstallationToken and UserToken redact
the key and every token; no error Display carries key material, a token, a
client secret or a JWT; and tracing calls emit metadata only (the
installation id, the status). The one message this crate does not write is
GithubAppError::Decode, which carries the JSON parser’s own words about a
body — diagnostic text, so a caller that prints it is printing something a
response chose. A key that is absent or empty makes every operation that
needs it — app_jwt, installation_token, request, paginate — answer
GithubAppError::NotConfigured without touching the network; the
user-to-server pair above runs without a key.
§Setting up the GitHub App (needs a human, per venture)
Creating the App is a one-time, human, per-venture step — nothing here can do it for you.
- Create the App. GitHub → Settings → Developer settings → GitHub Apps → New GitHub App. Give it a name and a homepage URL.
- Set the webhook. Under Webhook, set the payload URL to the venture’s inbound route and set a webhook secret. Choose the events the venture cares about.
- Choose permissions. Under Permissions & events, grant only what the venture needs (e.g. Contents: read, Issues: read and write). The installation token mints down to these; there is no path to more.
- Note the App ID and client ID. The App ID is
issin the JWT; the client ID is what the user-to-server flow sends. - Generate a client secret (Client secrets → Generate) if the
venture does the user-to-server flow. Store it in the venture’s secrets —
it is only ever sent to
github.com, never logged. - Download the private key (Private keys → Generate a private key).
GitHub sends a
.pemin the PKCS#1 form; store it in the venture’s secrets and hand it toGithubApp::new. - Install the App on the repositories the venture should reach, then
record the installation id (
GET /app/installations).
§Receiving its webhooks
GitHub signs every delivery with X-Hub-Signature-256 (HMAC-SHA256 over the
raw body), which core verifies with the named cratefield_core::Github
scheme:
use cratefield_core::{Github, WebhookVerifier};
let verifier = WebhookVerifier::new(Github);
if !verifier.verify(&webhook_secret, request.headers(), &raw_body, now_unix) {
return Err(/* 401 */);
}That scheme carries no timestamp — only the HMAC — so it offers no replay
protection of its own. A replayed delivery is rejected by claiming the
X-GitHub-Delivery id through the Inbox dedup ledger, which is what makes
each delivery apply its effects exactly once:
use cratefield_core::Inbox;
let inbox = Inbox::new("github_app_inbox");
// `seen_at` is an RFC 3339 timestamp read from the Clock port.
if !inbox.claim(db, delivery_id, &seen_at).await? {
return Ok(/* already handled */);
}The body must be the raw bytes GitHub sent — verify before any JSON parsing, or the canonical form will not match.
MIT. Built in the open for Cratefield, a Factory Zero venture.
Structs§
- Github
App - GitHub App auth over the
HttpClientandClockports. - Github
Response - A GitHub API response and the rate-limit information it carried. This
crate does not classify a non-2xx status (other than a replayed
401): the caller readsGithubResponse::statusand decides. - Installation
Token - An installation token: the credential (
ghs_…), when GitHub says it dies, and the permissions it carries.Debugnever prints the token. - Rate
Limit - GitHub’s rate-limit headers, read off any response.
- User
Token - A user-to-server access token (
ghu_…) and its refresh material.Debugnever prints a token.
Enums§
- Github
AppError - Everything that can go wrong. Every message this crate builds carries no
token, client secret, JWT or part of the private key — with one exception,
GithubAppError::Decode, which carries the JSON parser’s own words.
Constants§
- DEFAULT_
API_ BASE - The REST API root GitHub App requests default to.
- DEFAULT_
WEB_ BASE - The web root the user-to-server OAuth endpoint lives on. It is not the
API host:
/login/oauth/access_tokenis served by github.com (or the GitHub Enterprise Server web host).
Functions§
- next_
page - The URL of the
rel="next"link in aLinkheader, if there is one. Handles several comma-separated links and the quotedrelthey carry; commas inside a<…>URL are not separators. - with_
etag - A shortcut for adding
If-None-Match: <etag>to a request, so a caller can do a conditionalGETand recogniseGithubResponse::not_modified.