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, "

Authentication complete. You can close this tab and return to your terminal.

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