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) }