ghapp

package
v0.0.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 1, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package ghapp implements the GitHub App "manifest" connect flow: it creates a GitHub App in the user's org/account (no pre-registration needed), captures the generated credentials, and captures the installation id after the user installs the App. This gives multirunner production-grade App auth without a hand-made PAT.

Index

Constants

View Source
const DefaultAPIBase = "https://api.github.com"

DefaultAPIBase is the GitHub.com REST host used to list a user's App installations; GHES callers pass <base>/api/v3.

View Source
const DefaultAppSlug = "multirunner-connect"

DefaultAppSlug is the shared App's slug (display name "Multirunner Connect"), used to build an install URL when a user has not yet installed the App anywhere (so no installation reports one). Kept next to DefaultClientID because it can change with the App's name.

View Source
const DefaultBaseURL = "https://github.com"

DefaultBaseURL is the GitHub.com web host that serves the device and token endpoints; GHES callers pass their own base.

View Source
const DefaultClientID = "Iv23liZGKUct4sAKjq2m"

DefaultClientID is the client id of the shared multirunner GitHub App used by the device-flow connect path for organization targets. A GitHub App device flow uses no client secret, so this identifier is not sensitive; config may override it via auth.client_id.

It carries organization_self_hosted_runners:write, which covers org runners and nothing else. Repository runners need repository administration:write, a permission an org-scoped App must not hold, so that target uses a second App (DefaultPersonalClientID) rather than widening this one.

View Source
const DefaultPersonalAppSlug = "multirunner-connect-personal"

DefaultPersonalAppSlug is the repository App's slug, used the same way.

View Source
const DefaultPersonalClientID = "Iv23liZVB5CPke0jCFQi"

DefaultPersonalClientID is the client id of the shared App used for repository targets. It carries administration:write (plus metadata:read), which is what registering a repository runner requires.

View Source
const TokenSkew = 5 * time.Minute

TokenSkew refreshes a device-flow user token this long before it actually expires, so a request already in flight does not race the expiry.

Variables

This section is empty.

Functions

func CheckInstallationRepository

func CheckInstallationRepository(ctx context.Context, apiBase, accessToken string, installationID int64, owner, repo string) error

CheckInstallationRepository verifies the requested repository is included in the selected installation and visible to the authorizing user.

func CheckOwnerOnly

func CheckOwnerOnly(path string) error

CheckOwnerOnly reports whether path is reachable only by its owner: no group or world permission bits.

func DeviceBaseURL

func DeviceBaseURL(raw string) (string, error)

DeviceBaseURL normalizes GitHub.com aliases and requires encrypted OAuth endpoints. Literal loopback HTTP endpoints are allowed for local test servers; no hostname or remote address may receive a refresh token over HTTP.

func ManifestJSON

func ManifestJSON(opt Options, needs ManifestNeeds, callbackBase string) (string, error)

ManifestJSON renders the manifest that would be POSTed, indented, for review before any browser opens.

func SaveUserToken

func SaveUserToken(path string, tok *UserToken) error

SaveUserToken writes tok to path at mode 0600. The write goes to a temp file in the same directory and is renamed over path, so a crash mid-write cannot truncate an existing sidecar and lose the refresh token. os.Rename replaces an existing file on both Unix and Windows; the error is surfaced rather than assumed away.

func ValidateWebhookURL

func ValidateWebhookURL(raw string) error

ValidateWebhookURL rejects a webhook URL that GitHub cannot deliver to, so the failure surfaces before a browser opens rather than as a manifest rejection. connect only targets GitHub.com today (BaseURL is hardcoded), so requiring a publicly reachable https endpoint is correct.

func WriteSecretFile

func WriteSecretFile(path string, data []byte) error

WriteSecretFile writes a credential with access restricted to the owner (and LocalSystem on Windows), replacing any existing file. Use it for every credential connect persists: os.WriteFile's mode argument is ignored on Windows, so a plain 0600 write leaves the file readable by other local accounts.

Types

type Credentials

type Credentials struct {
	AppID          int64
	Slug           string
	PEM            string
	WebhookSecret  string
	InstallationID int64
	HTMLURL        string
}

Credentials is what the flow yields.

func Connect

func Connect(ctx context.Context, opt Options) (*Credentials, error)

Connect runs the full browser flow and returns the App credentials.

type DeviceCode

type DeviceCode struct {
	DeviceCode      string
	UserCode        string
	VerificationURI string
	ExpiresIn       int
	Interval        int
}

DeviceCode is the device/user code pair returned by the device authorization request.

func RequestDeviceCode

func RequestDeviceCode(ctx context.Context, clientID, baseURL string) (*DeviceCode, error)

RequestDeviceCode starts the device authorization flow and returns the code pair the caller must present to the user.

type Installation

type Installation struct {
	ID      int64
	Account string // login of the org/user the App is installed on
	// IsOrg distinguishes an organization installation from a personal one.
	// organization_self_hosted_runners only applies to orgs, so a personal
	// installation can never serve org runner management.
	IsOrg   bool
	AppSlug string
}

Installation is one App installation visible to a user access token.

func MatchInstallation

func MatchInstallation(installs []Installation, account string) (inst Installation, slug string, ok bool)

MatchInstallation returns the installation on account (case-insensitive), if any, alongside a best-effort App slug for building an install URL.

func UserInstallations

func UserInstallations(ctx context.Context, apiBase, accessToken string) ([]Installation, error)

UserInstallations lists the App installations the user access token can see, so connect can confirm the App is installed on the target account.

type ManifestNeeds

type ManifestNeeds struct {
	WebhookURL string // public https URL; empty means no active webhook
	PollQueue  bool   // repo-scope autoscale polls workflow runs/jobs
	DetectRepo bool   // `multirunner detect` reads trees/contents outside repo scope
}

ManifestNeeds describes what the host will do with the App, which determines the permissions and events the manifest is allowed to request. The scope is taken from Options.Scope, not duplicated here.

type Options

type Options struct {
	BaseURL string        // https://github.com (or GHES base)
	APIBase string        // https://api.github.com (or GHES <base>/api/v3)
	Scope   string        // "org" | "repo" | "user"
	Org     string        // org login (org scope)
	Owner   string        // repo owner (repo scope)
	Repo    string        // repo name (repo scope)
	Name    string        // desired App name
	Port    int           // local callback port (0 = auto)
	Needs   ManifestNeeds // what the host will do; drives permissions/events/hook
}

Options configures the connect flow.

type TokenRefresher

type TokenRefresher struct {
	// contains filtered or unexported fields
}

TokenRefresher hands out a valid device-flow user access token, refreshing and persisting a rotated one when the current token is expired or within TokenSkew of expiry.

Refreshing is a rotation: GitHub invalidates both the old access token and the old refresh token as soon as a refresh succeeds. Two refreshes racing on the same sidecar therefore leave one side holding a dead credential, so the coordination is deliberately broad:

  • Refreshers are shared per token path (see NewTokenRefresher), so every pool and every client in this process contends on one mutex rather than one each.
  • The refresh runs under a lock file next to the sidecar, so a second process (a concurrent `multirunner run`, or `doctor`) cannot rotate at the same time.
  • The sidecar is re-read inside both locks, so a token another process just rotated is picked up instead of being overwritten with a stale one.

It exists so the refresh, skew and token-store rules live in exactly one place rather than being copied into each transport.

func NewTokenRefresher

func NewTokenRefresher(clientID, baseURL, tokenPath string) (*TokenRefresher, error)

NewTokenRefresher returns the refresher for tokenPath, loading the sidecar on first use. clientID and baseURL fall back to the shared App's defaults when empty. Callers that share a token path share the returned refresher.

func (*TokenRefresher) AccessToken

func (r *TokenRefresher) AccessToken(ctx context.Context) (string, error)

AccessToken returns a valid access token, refreshing and persisting a new one under the mutex when the current token is near expiry.

func (*TokenRefresher) Current

func (r *TokenRefresher) Current() string

Current returns the token value most recently loaded or refreshed, without triggering a refresh. The scale-set library captures this value at client construction and re-sends it verbatim, so the transport uses it to recognise the credential it must rewrite.

type UserToken

type UserToken struct {
	AccessToken   string
	RefreshToken  string
	Expiry        time.Time
	RefreshExpiry time.Time
}

UserToken is a GitHub App user access token plus its refresh token. Expiry and RefreshExpiry are zero when the App does not expire user tokens.

func LoadUserToken

func LoadUserToken(path string) (*UserToken, error)

LoadUserToken reads a user access token sidecar written by SaveUserToken.

A sidecar other accounts can read is reported, not rejected: the token is already on disk by then, and refusing to start would take a host down over a file mode that drifted rather than protect anything still secret.

func PollDeviceToken

func PollDeviceToken(ctx context.Context, clientID, baseURL string, dc *DeviceCode) (*UserToken, error)

PollDeviceToken polls the token endpoint at the code's interval until the user authorizes, the code expires, or ctx is cancelled. slow_down responses widen the interval; expired_token and access_denied stop with an actionable error.

func RefreshUserToken

func RefreshUserToken(ctx context.Context, clientID, baseURL, refreshToken string) (*UserToken, error)

RefreshUserToken exchanges a refresh token for a new user access token. A GitHub error is turned into an actionable message rather than a silent stall, because the transport calls this on the request path.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL