git@cosmocr.at/gitweb.git @ main · summary refs log
main / cmd / git-credential-cosmocr / main.go /
cmd/git-credential-cosmocr/main.go · 2.8 KiB · raw
view: SourceMarkdown ☑ force
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
package main

import (
	"fmt"
	"net/http"
	"os"
	"os/exec"
	"runtime"
	"time"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: git-credential-cosmocr <get|store|erase>")
		os.Exit(2)
	}
	op := os.Args[1]
	attrs := parseAttrs(os.Stdin)
	c := cache{path: defaultCachePath()}
	host := attrs["host"]

	switch op {
	case "get":
		runGet(c, host, os.Stdout)
	case "erase":
		// git calls erase after the credential was rejected (e.g. token expired
		// server-side); drop it so the next get re-authenticates.
		_ = c.del(host)
	case "store":
		// The helper manages its own cache; nothing to do.
	default:
		fmt.Fprintf(os.Stderr, "git-credential-cosmocr: unknown operation %q\n", op)
		os.Exit(2)
	}
}

func runGet(c cache, host string, out *os.File) {
	// Serve a cached, still-valid token without a round trip.
	if tok, ok := c.get(host); ok && !expired(tok, time.Now()) {
		emit(out, tok)
		return
	}

	cfg := configFromEnv()
	tok, err := cfg.login(useDeviceFlow())
	if err != nil {
		fmt.Fprintf(cfg.stderr, "git-credential-cosmocr: %v\n", err)
		os.Exit(1)
	}
	if err := c.set(host, tok); err != nil {
		fmt.Fprintf(cfg.stderr, "git-credential-cosmocr: warning: could not cache token: %v\n", err)
	}
	emit(out, tok)
}

// emit writes the token to git as Basic-auth credentials. The username is the
// token's subject; git ignores it for our server but including it keeps the
// credential record meaningful.
func emit(out *os.File, tok string) {
	info, _ := decodeToken(tok)
	attrs := map[string]string{"username": info.Sub, "password": tok}
	if info.Exp > 0 {
		attrs["password_expiry_utc"] = fmt.Sprintf("%d", info.Exp)
	}
	writeAttrs(out, attrs)
}

func configFromEnv() config {
	vaultURL := os.Getenv("COSMOCR_VAULT_URL")
	if vaultURL == "" {
		vaultURL = "https://vault.cosmocr.at"
	}
	aud := os.Getenv("COSMOCR_AUDIENCE")
	if aud == "" {
		aud = "gitweb"
	}
	return config{
		vaultURL: vaultURL,
		audience: aud,
		client:   &http.Client{Timeout: 30 * time.Second},
		openURL:  openBrowser,
		stderr:   os.Stderr,
		timeout:  3 * time.Minute,
	}
}

// useDeviceFlow reports whether to skip the browser and use the device-code
// flow: when explicitly requested, or on a headless machine with no display.
func useDeviceFlow() bool {
	if os.Getenv("COSMOCR_DEVICE") == "1" {
		return true
	}
	switch runtime.GOOS {
	case "linux":
		return os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == ""
	default:
		return false
	}
}

// openBrowser best-effort launches the user's browser at url.
func openBrowser(url string) {
	var cmd string
	var args []string
	switch runtime.GOOS {
	case "darwin":
		cmd = "open"
	case "windows":
		cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler"}
	default:
		cmd = "xdg-open"
	}
	_ = exec.Command(cmd, append(args, url)...).Start()
}