Skip to main content

Crate cratefield_adapter_github_app

Crate cratefield_adapter_github_app 

Source
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 on crates.io cratefield-adapter-github-app documentation MIT

§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

WhatWhere it comes fromLifetime
App JWTapp_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_tokens1 hour; cached 55 minutes
User token (ghu_…)exchange_user_code(): the user-to-server OAuth code traded at /login/oauth/access_tokenas 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.

  1. Create the App. GitHub → Settings → Developer settings → GitHub Apps → New GitHub App. Give it a name and a homepage URL.
  2. 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.
  3. 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.
  4. Note the App ID and client ID. The App ID is iss in the JWT; the client ID is what the user-to-server flow sends.
  5. 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.
  6. Download the private key (Private keys → Generate a private key). GitHub sends a .pem in the PKCS#1 form; store it in the venture’s secrets and hand it to GithubApp::new.
  7. 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§

GithubApp
GitHub App auth over the HttpClient and Clock ports.
GithubResponse
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 reads GithubResponse::status and decides.
InstallationToken
An installation token: the credential (ghs_…), when GitHub says it dies, and the permissions it carries. Debug never prints the token.
RateLimit
GitHub’s rate-limit headers, read off any response.
UserToken
A user-to-server access token (ghu_…) and its refresh material. Debug never prints a token.

Enums§

GithubAppError
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_token is served by github.com (or the GitHub Enterprise Server web host).

Functions§

next_page
The URL of the rel="next" link in a Link header, if there is one. Handles several comma-separated links and the quoted rel they 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 conditional GET and recognise GithubResponse::not_modified.