githubauth

package
v0.2.0-rc.3 Latest Latest
Warning

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

Go to latest
Published: Nov 13, 2026 License: MIT Imports: 20 Imported by: 0

README

GitHub device authentication

This package owns the daemon's GitHub.com connection, local authentication API and token resolution. The desktop sign-in UI is implemented separately. Manager owns one active device authorization and one selected account. Its public Device and Status values exclude tokens and the device secret. Credential and Token are only for trusted daemon code, never API responses, logs or model environments.

The public OAuth client ID is Ov23liFSMjrhof9WQlOi. Device flow requests repo for private repositories and read:org for team review requests; new and rotated grants must include both (write:org and admin:org also satisfy organization read access). Start(ctx, true) also requests workflow, which merging pull requests that change GitHub Actions workflows needs. It is opt-in because it lets the token change workflows. Validation records whether GitHub reported the scope (Credential.Workflow, Status.Workflow), so the UI can say when it is missing; a credential saved before that field reads as without it. The browser uses GitHub's fixed device verification page. HTTP exchanges have a 15-second limit, a 64 KiB response limit, no redirects, and no upstream error text. Poll performs one exchange, enforces the server interval, and adds five seconds on slow_down. Callers should poll with a request context and stop at terminal states. Starting again replaces the previous pending authorization.

The OAuth App has token expiration turned off, so a grant has no expiry and no refresh token, and the credential lasts until the user revokes it. GitHub refreshes an OAuth App's expiring tokens only with the client secret, which a desktop binary cannot keep, so expiring grants from before that change are refreshed with the public client ID and refresh token, without a client secret, and any refusal asks the user to reconnect. Refresh is serialized with account changes. /user checks account identity and OAuth scopes before persistence. Rotated credentials must be saved before the new access token is returned. An unavailable store fails closed, with no plaintext fallback. Disconnect deletes the local credential; it does not revoke authorization on GitHub.

ProcessStore uses macOS Keychain, Windows Credential Manager, or Linux Secret Service through the pure-Go go-keyring package. The host is the service namespace (baum.github.com); the selected account and tokens occupy one atomic item (desktop-account), so replacing accounts leaves no previous account item. Enterprise device authorization and multiple simultaneous accounts are outside this first version. Native keyring operations run in the hidden baum internal github-credential helper with a 20-second deadline. Before reading a request or opening the keyring, the helper verifies its parent's executable identity using operating system process metadata and requires pipe input and output. On Linux the parent starts the helper from /proc/self/exe and the helper compares its own /proc/self/exe with the parent's, so a daemon whose file an update replaced keeps its credential access until it restarts into the new one. A shell or coding agent invoking the hidden command directly is refused, including save and delete requests. Credentials pass through private pipes. This trusts the installed baum executable and the OS process metadata; unsupported operating systems fail closed. It does not isolate credentials from arbitrary native code that can already access the user's keyring or modify the installed binary. Cancellation kills the helper process group (including macOS security), waits for process termination, then releases the manager lock. Lock acquisition is also cancellable. Native keyring code is private to the helper. No detached saves can race disconnect. Rotated grants remain in daemon memory until validation and persistence succeed; retries never reuse the spent refresh token. Lifetimes derive from the original token receipt time. If a retained access token nears expiry during an outage, its latest refresh token obtains a new pair before validation and persistence resume. A daemon crash before successful persistence can require reconnecting; credentials are never cached on disk.

Token caches the access token it reads in memory, with its account, so a request does not start the credential helper. A cache hit takes no manager lock and never waits behind a store read; callers that miss together queue on the lock and the first read fills the cache for the rest, so concurrent misses launch the helper once. An entry is trusted for ten minutes at most, which bounds how stale a credential replaced outside this process can be (a grant with no expiry would otherwise never be re-read), and never within a minute of the credential's own expiry, where Token refreshes instead. Sign-out, sign-in, a save, a refresh and any identity change drop the entry immediately, as does a 401 for the cached token reported through ObserveResponse (a 401 for an older token leaves a newer entry alone) and a Status read that finds a different saved account. A read that began before a drop cannot repopulate the cache. After a 401 the GraphQL transport resolves the token once more and resends the request once, only if that token differs and the body can be replayed. A REST discovery reader only drops the cache: it keeps its credential partition, so the next reader re-reads. A Status read also drops the entry when the saved credential is gone. A credential GitHub keeps rejecting costs one helper launch per rejected request. Explicit environment and config tokens never reach the manager.

Run just oss-go test -race ./internal/githubauth from the repository root.

The daemon shares one manager with configuration, GitHub HTTP clients and the local authentication controller. Explicit environment/config credentials take precedence. No GitHub CLI executable, token store or host registry is consulted. Enterprise credentials require an explicit matching GH_HOST; device sign-in is GitHub.com only. Cached GraphQL clients resolve credentials on every request (from the manager's in-memory cache); a discovery reader aborts if its credential partition changes mid-transaction.

The local /api/github-auth namespace uses the existing Host, session, Origin and CSRF boundary. GET observes safe status (state, host, login, source, override, saved, optional public pending, error, message). POST /start, /cancel, /disconnect and /retry mutate the connection. Go owns token polling; the browser never receives access/refresh tokens or device secrets. /retry rechecks a recovered credential store without starting a new authorization. Error codes include credential_store_unavailable, auth_request_failed, reconnect_required, insufficient_scopes, sso_required and permission_denied. Terminal device states include denied, expired and cancelled.

Automatic scheduling is fenced while device sign-in is pending and resumes on cancel/error expiry. An effective account change establishes fresh PR baselines without generating historical work. Existing preferences and saved reviews remain. In-flight discovery is cancelled and obsolete snapshots/observations cannot enter the new account's partition. Final review publication using the saved GitHub.com account is serialized against account replacement. Reviews using explicit environment/config or Enterprise credentials keep running and may publish when the unrelated saved account changes. Persistence attempts are conservatively treated as identity changes even on failure: a native helper may commit a write before its reply is lost. A failed write may require retrying the store or reconnecting; baum never assumes the durable state was unchanged.

HTTPS Git fetches use the selected host credential through Git's private child environment, scoped to the exact remote URL. Credential helpers are disabled for that fetch, redirects are refused, and neither argv nor disk configuration holds the token. Authenticated Git failure messages discard upstream stderr. SSH fetches retain the user's working configuration. Native credential-store and live GitHub OAuth smoke tests still require each supported OS and a real account.

The same token also lists the account's repositories for picking (github.Client.ViewerRepositories, GraphQL to the host's API endpoint, using the repository and organization access from that sign-in) and clones a picked repository (gitlocal.Clone, HTTPS to github.com with the same private-environment header as a fetch). A listing failure maps to the connection error codes above plus not_connected and rate_limited, using the runtime health this package keeps. See internal/githubrepos.

Manager.Organizations finds organizations that restrict OAuth app access. GitHub hides such an organization from an OAuth App token until an owner approves the app, with no error. The method compares the person's active memberships (GET /user/memberships/orgs?state=active) with GET /user/orgs, which GitHub documents as listing, for OAuth app tokens, only organizations the authorization allows acting on; a membership absent from the second list is hidden. Both need only the read:org scope already requested. Results are cached for five minutes per token (reset on account change) and pages stop at 300 organizations. The check is best effort: a failed read reports nothing rather than guessing. It is not run for explicit environment or config tokens, since a fine-grained token lists no organizations and would make every membership look hidden. ApprovalURL is baum's page in the member's GitHub settings (/settings/connections/applications/<client_id>), where a member requests approval. Not verified against a real restricted organization yet. A 403 with X-GitHub-SSO on any request, including the first /user check at sign-in, is ErrSSO. The status carries it as organization_access ({"hidden": [...], "approval_url": ""}, absent when nothing is hidden), refreshed in the background and at once by /retry.

Queued review confirmations include the credential partition inside the existing configuration digest. A job admitted just before switching accounts cannot start with the replacement account, including after daemon restart. Baum-issued credentials use the validated account ID for their private partition, so same-account refresh preserves queued confirmations. Explicit overrides use a token fingerprint and require a fresh start when replaced. A discovery reader still refuses an exact token change mid-transaction and retries with a new reader; a late old-token identity lookup cannot inherit a replacement account's ID.

Documentation

Overview

Package githubauth owns GitHub.com device authorization and credentials.

Index

Constants

View Source
const ApprovalURL = "https://github.com/settings/connections/applications/" + ClientID

ApprovalURL is the OAuth App's page in a member's GitHub settings, where they can ask an organization's owners to approve it.

View Source
const ClientID = "Ov23liFSMjrhof9WQlOi"
View Source
const Scopes = "repo read:org"

Scopes is what every sign-in requests. WorkflowScope is added only when the person asks for it, since it lets the token change GitHub Actions workflows.

View Source
const WorkflowScope = "workflow"

Variables

View Source
var ErrNotConnected = errors.New("GitHub is not connected")
View Source
var ErrPermissions = errors.New("check repository access and GitHub authorization permissions")
View Source
var ErrReconnect = errors.New("GitHub authorization expired or was revoked; reconnect GitHub")
View Source
var ErrRequest = errors.New("GitHub authentication request failed; try again")
View Source
var ErrSSO = errors.New("authorize baum for your organization's GitHub SSO and retry")
View Source
var ErrScopes = errors.New("GitHub authorization requires repo and read:org access")
View Source
var ErrStore = errors.New("GitHub credential store unavailable; unlock your operating system credential store and try again")

Functions

func ServeCredentialHelper

func ServeCredentialHelper(ctx context.Context, input io.Reader, output io.Writer) error

ServeCredentialHelper is only for the hidden CLI helper. The native store runs synchronously here; ProcessStore owns this process's deadline and waits for termination before releasing its caller's serialization lock.

Types

type Credential

type Credential struct {
	AccessToken      string `json:"access_token"`
	RefreshToken     string `json:"refresh_token,omitempty"`
	ExpiresAt        int64  `json:"expires_at,omitempty"`
	RefreshExpiresAt int64  `json:"refresh_expires_at,omitempty"`
	Login            string `json:"login"`
	ID               int64  `json:"id"`
	// Workflow records that GitHub reported the workflow scope for this token.
	Workflow bool `json:"workflow,omitempty"`
}

Credential is daemon-only state and must never be exposed by a public API. JSON is reserved for persistence in the operating system credential store.

func (Credential) GoString

func (Credential) GoString() string

func (Credential) String

func (Credential) String() string

String and GoString keep routine diagnostics from disclosing credentials.

type Device

type Device struct {
	ID              string    `json:"id"`
	UserCode        string    `json:"user_code"`
	VerificationURI string    `json:"verification_uri"`
	ExpiresAt       time.Time `json:"expires_at"`
	Interval        int       `json:"interval"`
	// Workflow is whether this sign-in requests workflow access.
	Workflow bool `json:"workflow,omitempty"`
}

type Manager

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

Manager serializes credential refresh, persistence and account replacement. Poll performs at most one exchange; callers can poll freely without exceeding GitHub's requested interval. Only one device authorization is active at a time.

func New

func New(store Store) *Manager

func NewWithOptions

func NewWithOptions(store Store, opts Options) *Manager

func (*Manager) AccountForToken

func (m *Manager) AccountForToken(token string) int64

AccountForToken identifies only the credential most recently issued by Token. A late lookup for an older token never inherits the replacement account ID.

func (*Manager) Cancel

func (m *Manager) Cancel(ctx context.Context, id string) error

func (*Manager) Disconnect

func (m *Manager) Disconnect(ctx context.Context) error

func (*Manager) Health

func (m *Manager) Health() error

func (*Manager) IdentityContext

func (m *Manager) IdentityContext(ctx context.Context) (context.Context, func())

IdentityContext cancels evidence work when the selected account changes. Token refresh does not cancel work because it retains the same identity.

func (*Manager) ObserveResponse

func (m *Manager) ObserveResponse(token string, status int, headers http.Header)

ObserveResponse ignores late failures for a credential no longer in use. Upstream bodies and header values are never retained or exposed.

func (*Manager) Organizations added in v0.2.0

func (m *Manager) Organizations(ctx context.Context, token string, refresh bool) (OrganizationAccess, error)

Organizations compares the person's active organization memberships (`GET /user/memberships/orgs`) with the organizations GitHub lets an OAuth app token operate on (`GET /user/orgs`). Both need only the `read:org` scope the sign-in already holds. GitHub documents the second list as limited to organizations the authorization allows acting on, so a member of an organization that has not approved the app appears only in the first. Results are cached for five minutes per token; refresh asks again.

func (*Manager) Poll

func (m *Manager) Poll(ctx context.Context, id string) (Status, error)

func (*Manager) Revision

func (m *Manager) Revision() uint64

func (*Manager) Start

func (m *Manager) Start(ctx context.Context, workflow bool) (Device, error)

Start begins a device sign-in. workflow adds the workflow scope, which only merges of pull requests that change GitHub Actions workflows need.

func (*Manager) Status

func (m *Manager) Status(ctx context.Context) (Status, error)

func (*Manager) Token

func (m *Manager) Token(ctx context.Context) (token string, returnedErr error)

Token returns the access token to use now. A token already read is served from memory without the manager's lock or the credential store; the store is read again only after the cache expires or is dropped (sign-out, account change, save, refresh, or a 401 from GitHub). Callers that miss together queue on the lock, and the first one's read fills the cache for the rest, so one helper launch serves them all.

func (*Manager) WithIdentity

func (m *Manager) WithIdentity(ctx context.Context, publish func() error) error

WithIdentity serializes final local publication with account replacement. No network or model work may run inside publish.

type Options

type Options struct{ Transport http.RoundTripper }

type OrganizationAccess added in v0.2.0

type OrganizationAccess struct {
	Hidden      []string `json:"hidden"`
	ApprovalURL string   `json:"approval_url"`
}

OrganizationAccess lists the organizations the signed-in person belongs to but this authorization cannot reach, which is how GitHub shows one that restricts OAuth app access until an owner approves the app. The list is best effort: it is empty when GitHub could not be asked.

type ProcessStore

type ProcessStore struct{ Executable string }

ProcessStore bounds native credential-store prompts by running them in a helper process. Secrets travel only through private pipes, never argv or env. Every operation waits for the helper to exit before returning, so cancellation cannot leave a detached save racing a later disconnect.

func (ProcessStore) Delete

func (s ProcessStore) Delete(ctx context.Context) error

func (ProcessStore) Load

func (s ProcessStore) Load(ctx context.Context) (Credential, error)

func (ProcessStore) Save

func (s ProcessStore) Save(ctx context.Context, c Credential) error

type Status

type Status struct {
	State     string `json:"state"`
	Login     string `json:"login,omitempty"`
	AccountID int64  `json:"account_id,omitempty"`
	// Workflow is whether the saved connection can merge pull requests that
	// change GitHub Actions workflows.
	Workflow bool `json:"workflow,omitempty"`
}

type Store

type Store interface {
	Load(context.Context) (Credential, error)
	Save(context.Context, Credential) error
	Delete(context.Context) error
}

Jump to

Keyboard shortcuts

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