// Package handlers — GitHub App installation-token refresh endpoint. // // GET /admin/github-installation-token returns a fresh GitHub App // installation token on demand. Long-running workspace containers use // this as a git credential helper and for explicit `gh auth` re-runs // so they never operate with an expired GH_TOKEN. // // # Why this endpoint? // // The github-app-auth plugin (PR #506) injects GH_TOKEN + GITHUB_TOKEN // into a workspace container's env at provision time. Those tokens are // GitHub App installation tokens with a fixed ~60 min TTL. The plugin // keeps a server-side in-process cache and proactively refreshes it // 5 min before expiry, but the workspace env is set once at container // start and never updated — so any workspace alive >60 min ends up with // an expired token (issue #547). // // The fix is: // // 1. Platform side (this file): expose GET /admin/github-installation-token. // The handler delegates to the registered TokenProvider (typically the // github-app-auth plugin), whose cache is always fresh. Gated behind // AdminAuth — any valid workspace bearer token can call it. // // 2. Workspace side: a shell credential helper // (workspace-template/scripts/molecule-git-token-helper.sh) configured // as the git credential helper. git calls it on every push/fetch; // it hits this endpoint and emits the fresh token to stdout. A 30-min // cron also runs `gh auth login --with-token` using the same helper. // // # Approach chosen // // Option B (pre-flight/on-demand): workspaces poll for a token when // they need one (credential helper callback). This is preferable over a // background goroutine pusher (Option A) because: // // - The plugin already maintains its own refresh cache — there is no // token to refresh on the platform side. // - Pushing a new token into running containers requires docker exec / // env mutation, which the architecture explicitly rejects (see issue // #547 "Alternatives considered"). // - On-demand is pull-based, stateless, and trivially testable. package handlers import ( "log" "net/http" "time" "github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook" "github.com/gin-gonic/gin" ) // GitHubTokenHandler serves GET /admin/github-installation-token. type GitHubTokenHandler struct { registry *provisionhook.Registry } // NewGitHubTokenHandler constructs the handler. registry may be nil when // no GitHub App plugin is registered (dev / self-hosted deployments). func NewGitHubTokenHandler(reg *provisionhook.Registry) *GitHubTokenHandler { return &GitHubTokenHandler{registry: reg} } // GetInstallationToken handles GET /admin/github-installation-token. // // Returns: // // 200 {"token": "ghs_...", "expires_at": "2026-04-17T22:50:00Z"} // 404 {"error": "no GitHub App configured"} — GITHUB_APP_ID not set // 404 {"error": "no token provider registered"} — plugin loaded but // doesn't implement TokenProvider // 500 {"error": "token refresh failed"} — provider returned error // // The 404 vs 403 distinction is intentional: a 404 means the feature is // simply not configured, not that the caller is forbidden. This matches // the pattern used by GET /admin/workspaces/:id/test-token. // // Callers must retry with exponential back-off on 500 — a transient // upstream GitHub API error should not permanently block git operations. func (h *GitHubTokenHandler) GetInstallationToken(c *gin.Context) { if h.registry == nil { c.JSON(http.StatusNotFound, gin.H{"error": "no GitHub App configured"}) return } provider := h.registry.FirstTokenProvider() if provider == nil { c.JSON(http.StatusNotFound, gin.H{"error": "no token provider registered"}) return } token, expiresAt, err := provider.Token(c.Request.Context()) if err != nil { log.Printf("[github] token refresh failed: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"}) return } if token == "" { log.Printf("[github] token provider returned empty token") c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed: empty token"}) return } // Never log the token itself. log.Printf("[github] served fresh installation token (expires %s, TTL %.0fs)", expiresAt.Format(time.RFC3339), time.Until(expiresAt).Seconds()) c.JSON(http.StatusOK, gin.H{ "token": token, "expires_at": expiresAt.UTC().Format(time.RFC3339), }) }