git@cosmocr.at/gitweb.git @ main · summary refs log
main / cmd / git-credential-cosmocr / login.go /
cmd/git-credential-cosmocr/login.go · 4.9 KiB · raw
view: SourceMarkdown ☑ force

package main

import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "time" )

// config holds the helper's runtime settings. type config struct { vaultURL string // e.g. "https://vault.cosmocr.at" audience string // e.g. "gitweb" client *http.Client // HTTP client (overridable in tests) openURL func(string) // browser opener (overridable in tests) stderr io.Writer // progress messages timeout time.Duration // overall login timeout }

// login obtains a fresh SSO token, using the loopback browser flow when a // browser is available and falling back to the device-code flow otherwise. func (cfg config) login(useDevice bool) (string, error) { if useDevice { return cfg.deviceLogin() } return cfg.loopbackLogin() }

// loopbackLogin starts a localhost callback server, opens the vault authorize // page in a browser, and waits for the redirect carrying the token. func (cfg config) loopbackLogin() (string, error) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return "", err } defer ln.Close()

state := randString(16)
tokenCh := make(chan string, 1)
errCh := make(chan error, 1)

srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/cb" {
		http.NotFound(w, r)
		return
	}
	q := r.URL.Query()
	if q.Get("state") != state {
		http.Error(w, "state mismatch", http.StatusBadRequest)
		errCh <- fmt.Errorf("callback state mismatch")
		return
	}
	tok := q.Get("token")
	if tok == "" {
		http.Error(w, "no token", http.StatusBadRequest)
		errCh <- fmt.Errorf("callback missing token")
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	io.WriteString(w, "<!doctype html><p>Authentication complete. You can close this tab and return to your terminal.</p>")
	tokenCh <- tok
})}
go srv.Serve(ln)
defer srv.Close()

redirectURI := fmt.Sprintf("http://%s/cb", ln.Addr().String())
authURL := fmt.Sprintf("%s/authorize?%s", strings.TrimRight(cfg.vaultURL, "/"), url.Values{
	"redirect_uri": {redirectURI},
	"state":        {state},
	"aud":          {cfg.audience},
}.Encode())

fmt.Fprintf(cfg.stderr, "Opening your browser to sign in:\n  %s\n", authURL)
if cfg.openURL != nil {
	cfg.openURL(authURL)
}

select {
case tok := <-tokenCh:
	return tok, nil
case err := <-errCh:
	return "", err
case <-time.After(cfg.timeout):
	return "", fmt.Errorf("timed out waiting for browser authentication")
}

}

// deviceLogin runs the RFC 8628-style device flow: request a code, tell the // user where to approve it, then poll until approved. func (cfg config) deviceLogin() (string, error) { var codeResp struct { DeviceCode string json:"device_code" UserCode string json:"user_code" VerificationURI string json:"verification_uri" Interval int json:"interval" ExpiresIn int json:"expires_in" } if err := cfg.postJSON("/api/device/code", map[string]string{"aud": cfg.audience}, &codeResp); err != nil { return "", err }

verify := codeResp.VerificationURI
if strings.HasPrefix(verify, "/") {
	verify = strings.TrimRight(cfg.vaultURL, "/") + verify
}
fmt.Fprintf(cfg.stderr,
	"To authenticate, open this URL in a browser, sign in with your passkey, and enter the code:\n  %s\n  code: %s\n",
	verify, codeResp.UserCode)

interval := time.Duration(codeResp.Interval) * time.Second
if interval <= 0 {
	interval = 5 * time.Second
}
deadline := time.Now().Add(cfg.timeout)
for time.Now().Before(deadline) {
	time.Sleep(interval)
	var tokResp struct {
		Status string `json:"status"`
		Token  string `json:"token"`
	}
	if err := cfg.postJSON("/api/device/token", map[string]string{"device_code": codeResp.DeviceCode}, &tokResp); err != nil {
		return "", err
	}
	switch tokResp.Status {
	case "approved":
		return tokResp.Token, nil
	case "slow_down":
		interval += 2 * time.Second
	case "pending":
		// keep polling
	case "expired":
		return "", fmt.Errorf("device code expired before approval")
	}
}
return "", fmt.Errorf("timed out waiting for device approval")

}

func (cfg config) postJSON(path string, body any, out any) error { buf, err := json.Marshal(body) if err != nil { return err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(cfg.vaultURL, "/")+path, strings.NewReader(string(buf))) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := cfg.client.Do(req) if err != nil { return err } defer resp.Body.Close() data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { return fmt.Errorf("%s: %s", path, strings.TrimSpace(string(data))) } return json.Unmarshal(data, out) }

func randString(n int) string { b := make([]byte, n) _, _ = rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) }