git@cosmocr.at/gitweb.git @ HEAD · summary refs log
HEAD / cmd / git-credential-cosmocr / login.go /
cmd/git-credential-cosmocr/login.go · 4.9 KiB · raw
view: Source ☐ 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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
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)
}