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
- func CheckInstallationRepository(ctx context.Context, apiBase, accessToken string, installationID int64, ...) error
- func CheckOwnerOnly(path string) error
- func DeviceBaseURL(raw string) (string, error)
- func ManifestJSON(opt Options, needs ManifestNeeds, callbackBase string) (string, error)
- func SaveUserToken(path string, tok *UserToken) error
- func ValidateWebhookURL(raw string) error
- func WriteSecretFile(path string, data []byte) error
- type Credentials
- type DeviceCode
- type Installation
- type ManifestNeeds
- type Options
- type TokenRefresher
- type UserToken
Constants ¶
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.
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.
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.
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.
const DefaultPersonalAppSlug = "multirunner-connect-personal"
DefaultPersonalAppSlug is the repository App's slug, used the same way.
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.
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 ¶
CheckOwnerOnly reports whether path is reachable only by its owner: no group or world permission bits.
func DeviceBaseURL ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
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.