From 16ca63cfec26bff86867c307e1f77b16db7706f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Salo=C5=88?= Date: Fri, 9 Oct 2026 20:12:25 +0200 Subject: [PATCH] z0 - browser based login --- CHANGELOG.md | 5 + go.mod | 10 +- go.sum | 22 +- src/browser/browser.go | 108 +++++++ src/browser/browser_test.go | 187 +++++++++++ src/cliStorage/handler.go | 17 +- src/cmd/login.go | 70 ++-- src/cmd/loginBrowser_integration_test.go | 392 +++++++++++++++++++++++ src/cmd/logout.go | 42 ++- src/cmd/projectEnv.go | 39 ++- src/cmd/root.go | 1 + src/cmd/sudo.go | 83 +++++ src/cmd/sudo_integration_test.go | 287 +++++++++++++++++ src/cmdBuilder/browserLogin.go | 97 ++++++ src/cmdBuilder/createRunFunc.go | 76 ++++- src/cmdBuilder/sudo.go | 83 +++++ src/constants/zerops.go | 1 + src/deviceLogin/deviceLogin.go | 116 +++++++ src/deviceLogin/deviceLogin_test.go | 249 ++++++++++++++ src/deviceLogin/fake_test.go | 248 ++++++++++++++ src/deviceLogin/model.go | 270 ++++++++++++++++ src/deviceLogin/model_test.go | 151 +++++++++ src/deviceLogin/plain.go | 46 +++ src/deviceLogin/request.go | 107 +++++++ src/i18n/en.go | 43 ++- src/i18n/i18n.go | 35 ++ src/loginSession/source.go | 148 +++++++++ src/loginSession/source_test.go | 224 +++++++++++++ src/storage/handler.go | 180 ++++++++--- src/storage/handler_test.go | 221 ++++++++++++- src/storage/rename_other.go | 9 + src/storage/rename_windows.go | 32 ++ src/terminal/terminal.go | 5 + src/zeropsRestApiClient/auth.go | 72 +++++ src/zeropsRestApiClient/auth_test.go | 148 +++++++++ src/zeropsRestApiClient/handler.go | 35 +- 36 files changed, 3714 insertions(+), 145 deletions(-) create mode 100644 src/browser/browser.go create mode 100644 src/browser/browser_test.go create mode 100644 src/cmd/loginBrowser_integration_test.go create mode 100644 src/cmd/sudo.go create mode 100644 src/cmd/sudo_integration_test.go create mode 100644 src/cmdBuilder/browserLogin.go create mode 100644 src/cmdBuilder/sudo.go create mode 100644 src/deviceLogin/deviceLogin.go create mode 100644 src/deviceLogin/deviceLogin_test.go create mode 100644 src/deviceLogin/fake_test.go create mode 100644 src/deviceLogin/model.go create mode 100644 src/deviceLogin/model_test.go create mode 100644 src/deviceLogin/plain.go create mode 100644 src/deviceLogin/request.go create mode 100644 src/loginSession/source.go create mode 100644 src/loginSession/source_test.go create mode 100644 src/storage/rename_other.go create mode 100644 src/storage/rename_windows.go create mode 100644 src/zeropsRestApiClient/auth.go create mode 100644 src/zeropsRestApiClient/auth_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8630ed31..af96499b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.1.3] - 2026-10-13 + +### Added +- browser based login and sudo mode using OAuth device login flow + ## [v1.1.2] - 2026-09-04 ### Added diff --git a/go.mod b/go.mod index 7179716b..d1351610 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,14 @@ module github.com/zeropsio/zcli go 1.26 -require github.com/zeropsio/zerops-go v1.0.23 +require github.com/zeropsio/zerops-go v1.0.24 require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.3.5 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/exp/teatest v0.0.0-20260511125431-fe5d686e0c99 + github.com/gofrs/flock v0.13.1 github.com/golang/mock v1.6.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 @@ -20,7 +21,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/spf13/viper v1.20.0 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.12.1 golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 golang.org/x/mod v0.35.0 golang.org/x/term v0.43.0 @@ -39,7 +40,6 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -51,7 +51,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect @@ -61,8 +60,9 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/sys v0.47.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index f6f51bdb..eed45a2c 100644 --- a/go.sum +++ b/go.sum @@ -27,18 +27,16 @@ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNE github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= +github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -77,8 +75,6 @@ github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xl github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -108,17 +104,19 @@ github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/zeropsio/zerops-go v1.0.23 h1:BtGSONtULSlcwjfF3t3ytq4t2BuFEFc6u+7Dy5/R5Qk= -github.com/zeropsio/zerops-go v1.0.23/go.mod h1:SY4o+4jAIrsDTwWACooogf1OyY1jNK8NqWBe9KFcjAA= +github.com/zeropsio/zerops-go v1.0.24 h1:acOf1s5thQEmi6EFXuTUqySp6WOvedXYWkK9Jql/aY0= +github.com/zeropsio/zerops-go v1.0.24/go.mod h1:SY4o+4jAIrsDTwWACooogf1OyY1jNK8NqWBe9KFcjAA= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= @@ -150,8 +148,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= diff --git a/src/browser/browser.go b/src/browser/browser.go new file mode 100644 index 00000000..c85ae437 --- /dev/null +++ b/src/browser/browser.go @@ -0,0 +1,108 @@ +package browser + +import ( + "context" + "net/url" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/pkg/errors" +) + +var ErrNoBrowser = errors.New("no browser available") + +// Open starts the default browser with rawURL and does not wait for it. +// It returns an error when no browser can be started. +func Open(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return errors.Wrap(err, "parse url") + } + // rundll32 and open also start local files and programs, so only web links are passed on. + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return errors.Errorf("unsupported url scheme %q", parsed.Scheme) + } + + h := host{ + goos: runtime.GOOS, + getenv: os.Getenv, + readFile: os.ReadFile, + lookPath: exec.LookPath, + } + argv, err := h.command(rawURL) + if err != nil { + return err + } + + // The process is not tied to a caller context, so cancelling the login never kills a browser that is starting up. + // Nil stdin, stdout and stderr connect to the null device, so launcher warnings never reach the terminal. + cmd := exec.CommandContext(context.Background(), argv[0], argv[1:]...) + if err := cmd.Start(); err != nil { + return errors.Wrapf(err, "start %s", argv[0]) + } + // The launcher exit code says nothing about the browser, so the process is only reaped. + go func() { _ = cmd.Wait() }() + return nil +} + +type host struct { + goos string + getenv func(string) string + readFile func(string) ([]byte, error) + lookPath func(string) (string, error) +} + +func (h host) command(rawURL string) ([]string, error) { + switch h.goos { + case "darwin": + return []string{"open", rawURL}, nil + case "windows": + // rundll32 hands the url to ShellExecute without a shell, so every & stays intact. + return []string{"rundll32", "url.dll,FileProtocolHandler", rawURL}, nil + case "linux": + // WSLg sets DISPLAY, so xdg-open would start a Linux browser inside WSL instead of the Windows one. + if h.isWSL() { + return h.wslCommand(rawURL) + } + return h.xdgCommand(rawURL) + case "freebsd", "openbsd", "netbsd", "dragonfly", "solaris", "illumos": + return h.xdgCommand(rawURL) + default: + return nil, errors.Wrapf(ErrNoBrowser, "unsupported OS %s", h.goos) + } +} + +func (h host) isWSL() bool { + if h.getenv("WSL_DISTRO_NAME") != "" { + return true + } + osRelease, err := h.readFile("/proc/sys/kernel/osrelease") + return err == nil && strings.Contains(strings.ToLower(string(osRelease)), "microsoft") +} + +// wslCommand never uses explorer.exe, which opens the Documents folder for some URLs. +// The absolute rundll32.exe path covers `appendWindowsPath = false` in /etc/wsl.conf, and a Linux browser in WSLg is the last resort. +func (h host) wslCommand(rawURL string) ([]string, error) { + if _, err := h.lookPath("wslview"); err == nil { + return []string{"wslview", rawURL}, nil + } + for _, name := range []string{"rundll32.exe", "/mnt/c/Windows/System32/rundll32.exe"} { + if _, err := h.lookPath(name); err == nil { + return []string{name, "url.dll,FileProtocolHandler", rawURL}, nil + } + } + return h.xdgCommand(rawURL) +} + +// xdgCommand never falls back to x-www-browser, www-browser or $BROWSER, which may start a text browser that takes over the terminal. +func (h host) xdgCommand(rawURL string) ([]string, error) { + if h.getenv("DISPLAY") == "" && h.getenv("WAYLAND_DISPLAY") == "" { + return nil, errors.Wrap(ErrNoBrowser, "no graphical session, DISPLAY and WAYLAND_DISPLAY are unset") + } + if _, err := h.lookPath("xdg-open"); err != nil { + return nil, errors.Wrap(ErrNoBrowser, "xdg-open not found") + } + return []string{"xdg-open", rawURL}, nil +} diff --git a/src/browser/browser_test.go b/src/browser/browser_test.go new file mode 100644 index 00000000..7e1a4326 --- /dev/null +++ b/src/browser/browser_test.go @@ -0,0 +1,187 @@ +package browser + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +const testURL = "https://app.zerops.io/device?code=ABCD-EFGH&lang=en" + +func TestCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + goos string + env map[string]string + osRelease string + onPath []string + want []string + }{ + { + name: "darwin", + goos: "darwin", + want: []string{"open", testURL}, + }, + { + name: "windows", + goos: "windows", + want: []string{"rundll32", "url.dll,FileProtocolHandler", testURL}, + }, + { + name: "wsl via env", + goos: "linux", + env: map[string]string{"WSL_DISTRO_NAME": "Ubuntu"}, + onPath: []string{"wslview", "rundll32.exe", "xdg-open"}, + want: []string{"wslview", testURL}, + }, + { + name: "wsl via osrelease", + goos: "linux", + osRelease: "6.6.87.2-microsoft-standard-WSL2\n", + onPath: []string{"wslview", "explorer.exe", "xdg-open"}, + want: []string{"wslview", testURL}, + }, + { + name: "wsl via osrelease in upper case", + goos: "linux", + osRelease: "4.4.0-19041-Microsoft\n", + onPath: []string{"rundll32.exe"}, + want: []string{"rundll32.exe", "url.dll,FileProtocolHandler", testURL}, + }, + { + name: "wsl without wslview uses rundll32.exe", + goos: "linux", + env: map[string]string{"WSL_DISTRO_NAME": "Ubuntu"}, + onPath: []string{"rundll32.exe", "explorer.exe", "xdg-open"}, + want: []string{"rundll32.exe", "url.dll,FileProtocolHandler", testURL}, + }, + { + name: "wsl without windows path uses absolute rundll32.exe", + goos: "linux", + env: map[string]string{"WSL_DISTRO_NAME": "Ubuntu", "DISPLAY": ":0"}, + onPath: []string{"/mnt/c/Windows/System32/rundll32.exe", "xdg-open"}, + want: []string{"/mnt/c/Windows/System32/rundll32.exe", "url.dll,FileProtocolHandler", testURL}, + }, + { + name: "wsl without windows launchers falls back to xdg-open", + goos: "linux", + env: map[string]string{"WSL_DISTRO_NAME": "Ubuntu", "DISPLAY": ":0"}, + onPath: []string{"explorer.exe", "xdg-open"}, + want: []string{"xdg-open", testURL}, + }, + { + name: "wsl without windows launchers and display", + goos: "linux", + env: map[string]string{"WSL_DISTRO_NAME": "Ubuntu"}, + }, + { + name: "wsl with display picks wslview", + goos: "linux", + env: map[string]string{"WSL_DISTRO_NAME": "Ubuntu", "DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}, + onPath: []string{"wslview", "rundll32.exe", "xdg-open"}, + want: []string{"wslview", testURL}, + }, + { + name: "wsl with display picks rundll32.exe", + goos: "linux", + env: map[string]string{"WSL_DISTRO_NAME": "Ubuntu", "DISPLAY": ":0"}, + onPath: []string{"rundll32.exe", "xdg-open"}, + want: []string{"rundll32.exe", "url.dll,FileProtocolHandler", testURL}, + }, + { + name: "linux with display", + goos: "linux", + env: map[string]string{"DISPLAY": ":0"}, + osRelease: "6.8.0-45-generic\n", + onPath: []string{"xdg-open", "wslview"}, + want: []string{"xdg-open", testURL}, + }, + { + name: "linux with wayland display", + goos: "linux", + env: map[string]string{"WAYLAND_DISPLAY": "wayland-0"}, + onPath: []string{"xdg-open"}, + want: []string{"xdg-open", testURL}, + }, + { + name: "linux headless", + goos: "linux", + onPath: []string{"xdg-open", "www-browser", "x-www-browser"}, + }, + { + name: "linux headless ignores BROWSER", + goos: "linux", + env: map[string]string{"BROWSER": "lynx"}, + }, + { + name: "linux without xdg-open", + goos: "linux", + env: map[string]string{"DISPLAY": ":0", "BROWSER": "firefox"}, + onPath: []string{"www-browser", "x-www-browser"}, + }, + { + name: "freebsd with display", + goos: "freebsd", + env: map[string]string{"DISPLAY": ":0"}, + onPath: []string{"xdg-open"}, + want: []string{"xdg-open", testURL}, + }, + { + name: "freebsd ignores wsl env", + goos: "freebsd", + env: map[string]string{"WSL_DISTRO_NAME": "Ubuntu", "DISPLAY": ":0"}, + onPath: []string{"xdg-open", "wslview"}, + want: []string{"xdg-open", testURL}, + }, + { + name: "unknown goos", + goos: "plan9", + env: map[string]string{"DISPLAY": ":0"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + h := host{ + goos: tt.goos, + getenv: func(key string) string { return tt.env[key] }, + readFile: func(name string) ([]byte, error) { + if name != "/proc/sys/kernel/osrelease" || tt.osRelease == "" { + return nil, os.ErrNotExist + } + return []byte(tt.osRelease), nil + }, + lookPath: func(file string) (string, error) { + for _, p := range tt.onPath { + if p == file { + return "/usr/bin/" + file, nil + } + } + return "", os.ErrNotExist + }, + } + + got, err := h.command(testURL) + if tt.want == nil { + require.ErrorIs(t, err, ErrNoBrowser) + require.Nil(t, got) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestOpenRejectsNonWebURL(t *testing.T) { + t.Parallel() + + for _, rawURL := range []string{"file:///etc/passwd", "C:\\Windows\\System32\\calc.exe", "-a Calculator", "javascript:alert(1)"} { + require.Error(t, Open(rawURL), rawURL) + } +} diff --git a/src/cliStorage/handler.go b/src/cliStorage/handler.go index 17b34cd5..a7997481 100644 --- a/src/cliStorage/handler.go +++ b/src/cliStorage/handler.go @@ -1,6 +1,8 @@ package cliStorage import ( + "time" + "github.com/zeropsio/zcli/src/entity" "github.com/zeropsio/zcli/src/region" "github.com/zeropsio/zcli/src/storage" @@ -12,8 +14,21 @@ type Handler struct { } type Data struct { - Token string + // Token is a static personal or integration token. External tools write it too, so its meaning must not change. + Token string + // LoginSession is set by the browser login. At most one of Token and LoginSession is set. + LoginSession LoginSession `json:",omitzero"` RegionData region.Item ScopeProjectId uuid.ProjectIdNull ProjectVpnKeyRegistry map[uuid.ProjectId]entity.VpnKey } + +type LoginSession struct { + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +func (s LoginSession) IsSet() bool { + return s.RefreshToken != "" +} diff --git a/src/cmd/login.go b/src/cmd/login.go index d94abfd3..8f7cfdf9 100644 --- a/src/cmd/login.go +++ b/src/cmd/login.go @@ -3,12 +3,14 @@ package cmd import ( "context" "fmt" + "strings" "time" "github.com/pkg/errors" "github.com/zeropsio/zcli/src/cliStorage" "github.com/zeropsio/zcli/src/cmdBuilder" "github.com/zeropsio/zcli/src/constants" + "github.com/zeropsio/zcli/src/errorsx" "github.com/zeropsio/zcli/src/httpClient" "github.com/zeropsio/zcli/src/i18n" "github.com/zeropsio/zcli/src/region" @@ -27,10 +29,20 @@ func loginCmd() *cmdBuilder.Cmd { StringFlag("region-url", constants.DefaultRegionUrl, i18n.T(i18n.RegionUrlFlag), cmdBuilder.HiddenFlag()). StringFlag("region", "prg1", i18n.T(i18n.RegionFlag), cmdBuilder.HiddenFlag()). HelpFlag(i18n.T(i18n.CmdHelpLogin)). - Arg("token"). + Arg("token", cmdBuilder.OptionalArg()). + BoolFlag("browser", false, i18n.T(i18n.LoginBrowserFlag)). GuestRunFunc(func(ctx context.Context, cmdData *cmdBuilder.GuestCmdData) error { uxBlocks := cmdData.UxBlocks + tokenArg, hasTokenArg := cmdData.Args["token"] + useBrowser := cmdData.Params.GetBool("browser") + if hasTokenArg && useBrowser { + return errorsx.NewUserError(i18n.T(i18n.LoginTokenAndBrowser), nil) + } + if hasTokenArg && strings.TrimSpace(tokenArg[0]) == "" { + return errorsx.NewUserError(i18n.T(i18n.LoginEmptyToken), nil) + } + regionRetriever := region.New(httpClient.New(ctx, httpClient.Config{HttpTimeout: time.Minute * 5})) regions, err := regionRetriever.RetrieveAllFromURL(ctx, cmdData.Params.GetString("region-url")) @@ -43,31 +55,45 @@ func loginCmd() *cmdBuilder.Cmd { return err } - restApiClient := zeropsRestApiClient.NewAuthorizedClient(cmdData.Args["token"][0], reg.Address) - - response, err := restApiClient.GetUserInfo(ctx) - if err != nil { - return err - } - - output, err := response.Output() - if err != nil { - return err + envToken := cmdBuilder.EnvToken() + switch { + case hasTokenArg: + return tokenLogin(ctx, cmdData, reg, tokenArg[0], true) + case envToken != "" && !useBrowser: + uxBlocks.PrintInfo(styles.InfoLine(i18n.T(i18n.LoginEnvToken, constants.CliTokenEnvVar))) + return tokenLogin(ctx, cmdData, reg, envToken, false) + case envToken != "": + uxBlocks.PrintWarning(styles.WarningLine(i18n.T(i18n.LoginEnvOverrides, constants.CliTokenEnvVar))) } + return cmdBuilder.BrowserLogin(ctx, cmdData, reg) + }) +} - _, err = cmdData.CliStorage.Update(func(data cliStorage.Data) cliStorage.Data { - data.Token = cmdData.Args["token"][0] - data.RegionData = reg - return data - }) - if err != nil { - return err - } +// tokenLogin verifies the token and stores the region, plus the token itself when persistToken is set. +func tokenLogin(ctx context.Context, cmdData *cmdBuilder.GuestCmdData, reg region.Item, token string, persistToken bool) error { + response, err := zeropsRestApiClient.NewAuthorizedClient(token, reg.Address).GetUserInfo(ctx) + if err != nil { + return err + } + output, err := response.Output() + if err != nil { + return err + } - uxBlocks.PrintInfo(styles.SuccessLine(i18n.T(i18n.LoginSuccess, output.FullName, output.Email))) + _, err = cmdData.CliStorage.Update(func(data cliStorage.Data) cliStorage.Data { + if persistToken { + data.Token = token + data.LoginSession = cliStorage.LoginSession{} + } + data.RegionData = reg + return data + }) + if err != nil { + return err + } - return nil - }) + cmdData.UxBlocks.PrintInfo(styles.SuccessLine(i18n.T(i18n.LoginSuccess, output.FullName, output.Email))) + return nil } func getLoginRegion( diff --git a/src/cmd/loginBrowser_integration_test.go b/src/cmd/loginBrowser_integration_test.go new file mode 100644 index 00000000..f8a9b1a4 --- /dev/null +++ b/src/cmd/loginBrowser_integration_test.go @@ -0,0 +1,392 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zeropsio/zcli/src/cliStorage" + "github.com/zeropsio/zcli/src/constants" +) + +const userInfoPath = "/api/rest/public/user/info" + +func (f *fixture) handleRegions() { + f.HandleJSON("/regions", 200, map[string]any{ + "items": []map[string]any{{"name": "prg1", "isDefault": true, "address": f.Server.URL}}, + }) +} + +func (f *fixture) seedData(data cliStorage.Data) { + f.t.Helper() + data.RegionData = f.Region + b, err := json.Marshal(data) + require.NoError(f.t, err, "marshal seed data") + require.NoError(f.t, os.WriteFile(f.DataPath, b, 0o600), "write seed data") +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + b, err := json.Marshal(body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(b) +} + +func apiErrorBody(code string) map[string]any { + return map[string]any{"error": map[string]any{"code": code, "message": code}} +} + +// handleUserInfo answers user info only for a request with the given token. +func (f *fixture) handleUserInfo(validToken string) *atomic.Int32 { + var unauthorized atomic.Int32 + f.Mux.HandleFunc(userInfoPath, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+validToken { + unauthorized.Add(1) + writeJSON(w, http.StatusUnauthorized, apiErrorBody("unauthorized")) + return + } + writeJSON(w, http.StatusOK, map[string]any{"email": "tester@example.com", "fullName": "Test User"}) + }) + return &unauthorized +} + +func authFull(accessToken, refreshToken string) map[string]any { + return map[string]any{ + "accessToken": accessToken, + "refreshToken": refreshToken, + "expiresAt": time.Now().Add(6 * time.Hour).UTC().Format(time.RFC3339), + } +} + +// handleDeviceLogin approves or denies the request on the first check. +func (f *fixture) handleDeviceLogin(status string) *[]map[string]any { + var lock sync.Mutex + var checks []map[string]any + f.HandleJSON("/api/rest/public/auth/device-login", 200, map[string]any{ + "requestId": "MY9Z7FZkTUyuTacCH9RoIw", + "pollSecret": "poll-secret", + "userCode": "DK0D-GF7C", + "verificationUrl": "https://app.example.com/authorize-login?request=MY9Z7FZkTUyuTacCH9RoIw", + "expiresIn": 300, + "interval": 1, + }) + f.Mux.HandleFunc("/api/rest/public/auth/device-login/check", func(w http.ResponseWriter, r *http.Request) { + var in map[string]any + _ = json.NewDecoder(r.Body).Decode(&in) + lock.Lock() + checks = append(checks, in) + lock.Unlock() + response := map[string]any{"status": status, "auth": nil} + if status == "APPROVED" { + response["auth"] = map[string]any{ + "auth": authFull("access-1", "refresh-1"), + "user": map[string]any{"email": "tester@example.com", "fullName": "Test User"}, + } + } + writeJSON(w, http.StatusOK, response) + }) + return &checks +} + +func TestLogin_TokenReplacesBrowserSession(t *testing.T) { + f := newFixture(t) + f.handleRegions() + f.handleUserInfo("personal") + f.seedData(cliStorage.Data{LoginSession: cliStorage.LoginSession{AccessToken: "a", RefreshToken: "r", ExpiresAt: time.Now().Add(time.Hour)}}) + + res := f.Run("login", "personal", "--region-url", f.Server.URL+"/regions") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + stored := f.LoadStorage() + assert.Equal(t, "personal", stored.Token) + assert.False(t, stored.LoginSession.IsSet()) +} + +func TestLogin_RejectsEmptyTokenAndTokenWithBrowser(t *testing.T) { + f := newFixture(t) + + res := f.Run("login", "") + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "The token is empty") + + res = f.Run("login", "personal", "--browser") + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "either a token or the --browser flag") +} + +func TestLogin_EnvTokenIsVerifiedAndNotStored(t *testing.T) { + f := newFixture(t) + f.handleRegions() + f.handleUserInfo("from-env") + t.Setenv(constants.CliTokenEnvVar, "from-env") + + res := f.Run("login", "--region-url", f.Server.URL+"/regions") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Contains(t, res.Stderr, "Using the token from the ZEROPS_TOKEN env variable") + assert.Contains(t, res.Stderr, "Test User") + stored := f.LoadStorage() + assert.Empty(t, stored.Token) + assert.Equal(t, f.Server.URL, stored.RegionData.Address) +} + +func TestLogin_InvalidEnvTokenFailsWithoutBrowser(t *testing.T) { + f := newFixture(t) + f.handleRegions() + f.handleUserInfo("other") + checks := f.handleDeviceLogin("APPROVED") + t.Setenv(constants.CliTokenEnvVar, "from-env") + + res := f.Run("login", "--region-url", f.Server.URL+"/regions") + + require.NotEqual(t, 0, res.ExitCode) + assert.Empty(t, *checks) +} + +func TestLogin_BrowserIsRefusedInCI(t *testing.T) { + f := newFixture(t) + f.handleRegions() + t.Setenv("CI", "true") + + res := f.Run("login", "--region-url", f.Server.URL+"/regions") + + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "Browser login is not available in CI") +} + +func TestLogin_BrowserApprovedStoresSession(t *testing.T) { + f := newFixture(t) + f.handleRegions() + checks := f.handleDeviceLogin("APPROVED") + t.Setenv("CI", "") + f.seedData(cliStorage.Data{Token: "personal"}) + + res := f.Run("login", "--region-url", f.Server.URL+"/regions") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Contains(t, res.Stderr, "DK0D-GF7C") + assert.Contains(t, res.Stderr, "https://app.example.com/authorize-login?request=MY9Z7FZkTUyuTacCH9RoIw") + assert.Contains(t, res.Stderr, "Test User") + assert.NotContains(t, res.Stderr+res.Stdout, "poll-secret") + require.Len(t, *checks, 1) + assert.Equal(t, "poll-secret", (*checks)[0]["pollSecret"]) + + stored := f.LoadStorage() + assert.Empty(t, stored.Token) + assert.Equal(t, "access-1", stored.LoginSession.AccessToken) + assert.Equal(t, "refresh-1", stored.LoginSession.RefreshToken) +} + +func TestLogin_BrowserDenied(t *testing.T) { + f := newFixture(t) + f.handleRegions() + f.handleDeviceLogin("DENIED") + t.Setenv("CI", "") + f.seedData(cliStorage.Data{}) + + res := f.Run("login", "--region-url", f.Server.URL+"/regions") + + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "denied") + assert.False(t, f.LoadStorage().LoginSession.IsSet()) +} + +func TestLoggedCommand_WithoutTokenDoesNotOfferBrowserLoginWithoutTerminal(t *testing.T) { + f := newFixture(t) + checks := f.handleDeviceLogin("APPROVED") + + res := f.Run("project", "list") + + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "unauthenticated user") + assert.Empty(t, *checks) +} + +// handleRefresh rotates refresh-0 into access-1/refresh-1 once, like Zerops. +func (f *fixture) handleRefresh(valid string) *atomic.Int32 { + var calls atomic.Int32 + f.Mux.HandleFunc("/api/rest/public/auth/refresh", func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + var in struct { + RefreshTokenId string `json:"refreshTokenId"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + if in.RefreshTokenId != valid { + writeJSON(w, http.StatusBadRequest, apiErrorBody("refreshTokenInvalid")) + return + } + writeJSON(w, http.StatusOK, authFull("access-1", "refresh-1")) + }) + return &calls +} + +func TestLoggedCommand_RefreshesRefusedSessionToken(t *testing.T) { + f := newFixture(t) + unauthorized := f.handleUserInfo("access-1") + refreshes := f.handleRefresh("refresh-0") + f.seedData(cliStorage.Data{LoginSession: cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Hour)}}) + + res := f.Run() + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Contains(t, res.Stdout+res.Stderr, "Test User") + assert.EqualValues(t, 1, unauthorized.Load()) + assert.EqualValues(t, 1, refreshes.Load()) + stored := f.LoadStorage() + assert.Equal(t, "access-1", stored.LoginSession.AccessToken) + assert.Equal(t, "refresh-1", stored.LoginSession.RefreshToken) +} + +func TestLoggedCommand_RefreshesAheadOfExpiry(t *testing.T) { + f := newFixture(t) + unauthorized := f.handleUserInfo("access-1") + refreshes := f.handleRefresh("refresh-0") + f.seedData(cliStorage.Data{LoginSession: cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Minute)}}) + + res := f.Run() + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Zero(t, unauthorized.Load()) + assert.EqualValues(t, 1, refreshes.Load()) +} + +func TestLoggedCommand_ExpiredSessionAsksForLogin(t *testing.T) { + f := newFixture(t) + f.handleRefresh("other") + f.HandleJSON("/api/rest/public/user/client-list", http.StatusUnauthorized, apiErrorBody("unauthorized")) + f.seedData(cliStorage.Data{LoginSession: cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Hour)}}) + + res := f.Run("project", "list") + + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "Your login session expired") + assert.False(t, f.LoadStorage().LoginSession.IsSet()) +} + +func (f *fixture) handleLogout() *[]string { + var lock sync.Mutex + var tokens []string + f.Mux.HandleFunc("/api/rest/public/auth/logout", func(w http.ResponseWriter, r *http.Request) { + lock.Lock() + tokens = append(tokens, r.Header.Get("Authorization")) + lock.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"success": true}) + }) + return &tokens +} + +func TestLogout_RevokesBrowserSession(t *testing.T) { + f := newFixture(t) + tokens := f.handleLogout() + f.seedData(cliStorage.Data{LoginSession: cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Hour)}}) + + res := f.Run("logout") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Equal(t, []string{"Bearer access-0"}, *tokens) + assert.False(t, f.LoadStorage().LoginSession.IsSet()) +} + +func TestLogout_KeepsPersonalTokenValid(t *testing.T) { + f := newFixture(t) + tokens := f.handleLogout() + f.SeedLogin("personal") + + res := f.Run("logout") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Empty(t, *tokens) + assert.Empty(t, f.LoadStorage().Token) +} + +func TestLogout_EnvTokenOnly(t *testing.T) { + f := newFixture(t) + tokens := f.handleLogout() + t.Setenv(constants.CliTokenEnvVar, "from-env") + + res := f.Run("logout") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Empty(t, *tokens) + assert.Contains(t, res.Stderr, "still authenticated by the ZEROPS_TOKEN env variable") +} + +func TestLogin_BrowserFlagWithEnvTokenWarnsAndLogsIn(t *testing.T) { + f := newFixture(t) + f.handleRegions() + checks := f.handleDeviceLogin("APPROVED") + t.Setenv("CI", "") + t.Setenv(constants.CliTokenEnvVar, "from-env") + f.seedData(cliStorage.Data{}) + + res := f.Run("login", "--browser", "--region-url", f.Server.URL+"/regions") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Contains(t, res.Stderr, "takes precedence over the browser login") + assert.Len(t, *checks, 1) + assert.Equal(t, "refresh-1", f.LoadStorage().LoginSession.RefreshToken) +} + +func TestLoggedCommand_TokenPrecedence(t *testing.T) { + session := cliStorage.LoginSession{AccessToken: "session", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Hour)} + tests := []struct { + name string + env string + data cliStorage.Data + expected string + }{ + {name: "env wins over stored token", env: "from-env", data: cliStorage.Data{Token: "personal"}, expected: "from-env"}, + {name: "env wins over session", env: "from-env", data: cliStorage.Data{LoginSession: session}, expected: "from-env"}, + {name: "stored token", data: cliStorage.Data{Token: "personal"}, expected: "personal"}, + {name: "session", data: cliStorage.Data{LoginSession: session}, expected: "session"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newFixture(t) + unauthorized := f.handleUserInfo(tt.expected) + t.Setenv(constants.CliTokenEnvVar, tt.env) + f.seedData(tt.data) + + res := f.Run() + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Zero(t, unauthorized.Load()) + }) + } +} + +func TestRootCommand_ExpiredSessionAsksForLogin(t *testing.T) { + f := newFixture(t) + f.handleRefresh("other") + f.handleUserInfo("never") + f.seedData(cliStorage.Data{LoginSession: cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Hour)}}) + + res := f.Run() + + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "Your login session expired") +} + +func TestLogout_IsIdempotent(t *testing.T) { + f := newFixture(t) + tokens := f.handleLogout() + f.SeedLogin("personal") + + first := f.Run("logout") + second := f.Run("logout") + + require.Equalf(t, 0, first.ExitCode, "stderr=%q", first.Stderr) + require.Equalf(t, 0, second.ExitCode, "stderr=%q", second.Stderr) + assert.Contains(t, second.Stderr, "You are not logged in.") + assert.Empty(t, *tokens) +} diff --git a/src/cmd/logout.go b/src/cmd/logout.go index 11637995..ecfbdabb 100644 --- a/src/cmd/logout.go +++ b/src/cmd/logout.go @@ -3,10 +3,15 @@ package cmd import ( "context" + "github.com/zeropsio/zcli/src/cliStorage" "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/constants" "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/loginSession" + "github.com/zeropsio/zcli/src/uxBlock" "github.com/zeropsio/zcli/src/uxBlock/styles" "github.com/zeropsio/zcli/src/wg" + "github.com/zeropsio/zcli/src/zeropsRestApiClient" ) func logoutCmd() *cmdBuilder.Cmd { @@ -14,15 +19,18 @@ func logoutCmd() *cmdBuilder.Cmd { Use("logout"). Short(i18n.T(i18n.CmdDescLogout)). HelpFlag(i18n.T(i18n.CmdHelpLogout)). - LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + GuestRunFunc(func(ctx context.Context, cmdData *cmdBuilder.GuestCmdData) error { uxBlocks := cmdData.UxBlocks + data := cmdData.CliStorage.Data() + envToken := cmdBuilder.EnvToken() + hasStoredLogin := data.Token != "" || data.LoginSession.IsSet() - _, err := cmdData.RestApiClient.PostAuthLogout(ctx) - if err != nil { - return err + // Personal tokens stay valid, only a browser login is revoked on the server. + if data.LoginSession.IsSet() { + revokeLoginSession(ctx, uxBlocks, cmdData.CliStorage, data.RegionData.Address) } - _, err = cmdData.CliStorage.Clear() + _, err := cmdData.CliStorage.Clear() if err != nil { return err } @@ -35,8 +43,30 @@ func logoutCmd() *cmdBuilder.Cmd { if vpnActive { _ = disconnectVpn(ctx, uxBlocks, false, false) } - uxBlocks.PrintInfo(styles.SuccessLine(i18n.T(i18n.LogoutSuccess))) + switch { + case envToken != "": + uxBlocks.PrintWarning(styles.WarningLine(i18n.T(i18n.LogoutEnvToken, constants.CliTokenEnvVar))) + case !hasStoredLogin: + uxBlocks.PrintInfo(styles.InfoLine(i18n.T(i18n.LogoutNotLoggedIn))) + default: + uxBlocks.PrintInfo(styles.SuccessLine(i18n.T(i18n.LogoutSuccess))) + } return nil }) } + +// revokeLoginSession goes through the refreshing source, because logging out with an expired access token would leave its refresh token valid. +func revokeLoginSession(ctx context.Context, uxBlocks uxBlock.UxBlocks, storage *cliStorage.Handler, host string) { + if host == "" { + host = constants.DefaultRegion + } + source := loginSession.New(storage, zeropsRestApiClient.NewClient(host)) + response, err := zeropsRestApiClient.NewTokenSourceClient(source, host).PostAuthLogout(ctx) + if err == nil { + _, err = response.Output() + } + if err != nil && !source.Expired() { + uxBlocks.PrintWarning(styles.WarningLine(i18n.T(i18n.LogoutRevokeFailed, err.Error()))) + } +} diff --git a/src/cmd/projectEnv.go b/src/cmd/projectEnv.go index e381bb23..3c963ae9 100644 --- a/src/cmd/projectEnv.go +++ b/src/cmd/projectEnv.go @@ -11,8 +11,8 @@ import ( "github.com/zeropsio/zcli/src/cmdBuilder" "github.com/zeropsio/zerops-go/dto/input/path" "github.com/zeropsio/zerops-go/dto/input/query" + dtoOutput "github.com/zeropsio/zerops-go/dto/output" "github.com/zeropsio/zerops-go/types" - "github.com/zeropsio/zerops-go/types/enum" ) func projectEnvCmd() *cmdBuilder.Cmd { @@ -25,6 +25,7 @@ func projectEnvCmd() *cmdBuilder.Cmd { StringFlag("template", "{{.Key}}={{.Value}}", "Output template."). StringFlag("service", "", "Service name, in which context the environment variables are output."). BoolFlag("user-only", false, "Exclude all non user env variables."). + BoolFlag("reveal", false, "Print the values of sensitive env variables. Needs sudo mode."). LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { project, err := cmdData.Project.Expect("project is nil") if err != nil { @@ -37,10 +38,10 @@ func projectEnvCmd() *cmdBuilder.Cmd { } var serviceName types.EmptyString - overrideEnvIsolation := enum.GetProjectEnvFileOverrideEnvIsolationEnumNone + overrideEnvIsolation := types.NewString("none") if value := cmdData.Params.GetString("service"); value != "" { serviceName = types.NewEmptyString(value) - overrideEnvIsolation = enum.GetProjectEnvFileOverrideEnvIsolationEnumService + overrideEnvIsolation = types.NewString("service") } userOnly := cmdData.Params.GetBool("user-only") @@ -50,20 +51,24 @@ func projectEnvCmd() *cmdBuilder.Cmd { return errors.WithStack(err) } - response, err := cmdData.RestApiClient.GetProjectEnvFile( - ctx, - path.ProjectId{Id: project.Id}, - query.GetProjectEnvFile{ - Name: serviceName, - OverrideEnvIsolation: overrideEnvIsolation, - UserOnly: types.NewBool(userOnly), - }, - ) - if err != nil { - return errors.WithStack(err) - } - - envs, err := response.Output() + var envs dtoOutput.EnvFile + err = cmdData.WithSudo(ctx, func() error { + response, err := cmdData.RestApiClient.GetProjectEnvFile( + ctx, + path.ProjectId{Id: project.Id}, + query.GetProjectEnvFile{ + Name: serviceName, + OverrideEnvIsolation: overrideEnvIsolation, + UserOnly: types.NewBool(userOnly), + Reveal: types.NewBool(cmdData.Params.GetBool("reveal")), + }, + ) + if err != nil { + return err + } + envs, err = response.Output() + return err + }) if err != nil { return errors.WithStack(err) } diff --git a/src/cmd/root.go b/src/cmd/root.go index 6fbb7c20..ebd63463 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -25,6 +25,7 @@ func rootCmd() *cmdBuilder.Cmd { SilenceError(true). AddChildrenCmd(loginCmd()). AddChildrenCmd(logoutCmd()). + AddChildrenCmd(sudoCmd()). AddChildrenCmd(versionCmd()). AddChildrenCmd(upgradeCmd()). AddChildrenCmd(scopeCmd()). diff --git a/src/cmd/sudo.go b/src/cmd/sudo.go new file mode 100644 index 00000000..51258f6e --- /dev/null +++ b/src/cmd/sudo.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "context" + "fmt" + "time" + + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/errorsx" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/terminal" + "github.com/zeropsio/zcli/src/uxBlock/models/prompt" + "github.com/zeropsio/zcli/src/uxBlock/styles" + "github.com/zeropsio/zcli/src/uxHelpers" + "github.com/zeropsio/zerops-go/dto/output" +) + +func sudoCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("sudo"). + Short(i18n.T(i18n.CmdDescSudo)). + HelpFlag(i18n.T(i18n.CmdHelpSudo)). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + mode, err := getSudoMode(ctx, cmdData) + if err != nil { + return err + } + if mode.InSudo.Native() { + printSudoOn(cmdData, mode) + return nil + } + + cmdData.UxBlocks.PrintInfo(styles.InfoLine(i18n.T(i18n.SudoOff))) + if !cmdData.IsLoginSession() { + return errorsx.NewUserError(i18n.T(i18n.SudoTokenWithout), nil) + } + if terminal.IsInteractive() { + confirmed, err := uxHelpers.YesNoPrompt(ctx, i18n.T(i18n.SudoEnablePrompt), prompt.WithCursorPosition(1)) + if err != nil || !confirmed { + return err + } + } + if err := cmdData.EnableSudo(ctx); err != nil { + return err + } + + mode, err = getSudoMode(ctx, cmdData) + if err != nil { + return err + } + if !mode.InSudo.Native() { + return errorsx.NewUserError(i18n.T(i18n.SudoNotApplied), nil) + } + printSudoOn(cmdData, mode) + return nil + }) +} + +func getSudoMode(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) (output.SudoMode, error) { + response, err := cmdData.RestApiClient.GetAuthSudo(ctx) + if err != nil { + return output.SudoMode{}, err + } + return response.Output() +} + +// printSudoOn shows the expiry only for a login session, a token with sudo mode keeps it as long as the token is valid. +func printSudoOn(cmdData *cmdBuilder.LoggedUserCmdData, mode output.SudoMode) { + until, ok := mode.SudoUntil.Get() + if !cmdData.IsLoginSession() || !ok { + cmdData.UxBlocks.PrintInfo(styles.SuccessLine(i18n.T(i18n.SudoOn))) + return + } + cmdData.UxBlocks.PrintInfo(styles.SuccessLine(i18n.T(i18n.SudoOnFor, remaining(time.Until(until.Native()))))) +} + +func remaining(d time.Duration) string { + minutes := max(int(d.Round(time.Minute)/time.Minute), 1) + if minutes < 60 { + return fmt.Sprintf("%dm", minutes) + } + return fmt.Sprintf("%dh %dm", minutes/60, minutes%60) +} diff --git a/src/cmd/sudo_integration_test.go b/src/cmd/sudo_integration_test.go new file mode 100644 index 00000000..e0ec41de --- /dev/null +++ b/src/cmd/sudo_integration_test.go @@ -0,0 +1,287 @@ +package cmd + +import ( + "net/http" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zeropsio/zcli/src/cliStorage" +) + +// sudoServer keeps the sudo mode of each token. Approving a sudo device login switches the token that started it. +type sudoServer struct { + lock sync.Mutex + inSudo map[string]bool + status string + // approvalIgnored leaves the session out of sudo mode after an approval. + approvalIgnored bool + sudoStarts []string + envFileReveal []string +} + +func (f *fixture) handleSudo(status string, inSudo ...string) *sudoServer { + s := &sudoServer{inSudo: map[string]bool{}, status: status} + for _, token := range inSudo { + s.inSudo[token] = true + } + f.Mux.HandleFunc("/api/rest/public/auth/sudo", func(w http.ResponseWriter, r *http.Request) { + s.lock.Lock() + defer s.lock.Unlock() + if !s.inSudo[bearer(r)] { + writeJSON(w, http.StatusOK, map[string]any{"inSudo": false, "sudoUntil": nil}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"inSudo": true, "sudoUntil": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339)}) + }) + var startedBy string + f.Mux.HandleFunc("/api/rest/public/auth/device-login/sudo", func(w http.ResponseWriter, r *http.Request) { + s.lock.Lock() + defer s.lock.Unlock() + startedBy = bearer(r) + s.sudoStarts = append(s.sudoStarts, startedBy) + writeJSON(w, http.StatusOK, map[string]any{ + "requestId": "MY9Z7FZkTUyuTacCH9RoIw", + "pollSecret": "poll-secret", + "userCode": "DK0D-GF7C", + "verificationUrl": "https://app.example.com/authorize-login?request=MY9Z7FZkTUyuTacCH9RoIw", + "expiresIn": 300, + "interval": 1, + }) + }) + f.Mux.HandleFunc("/api/rest/public/auth/device-login/check", func(w http.ResponseWriter, _ *http.Request) { + s.lock.Lock() + defer s.lock.Unlock() + if s.status == "APPROVED" && !s.approvalIgnored { + s.inSudo[startedBy] = true + } + writeJSON(w, http.StatusOK, map[string]any{"status": s.status, "auth": nil}) + }) + return s +} + +// handleEnvFile refuses reveal=true for a token without sudo mode, like Zerops. +func (f *fixture) handleEnvFile(s *sudoServer) { + f.HandleJSON( + "/api/rest/public/project/"+pushProjectID, + 200, + map[string]any{ + "id": pushProjectID, + "clientId": pushClientID, + "name": "demo-project", + "mode": "LIGHT", + "status": "ACTIVE", + "created": "2024-01-01T00:00:00.000Z", + "lastUpdate": "2024-01-01T00:00:00.000Z", + "tagList": []string{}, + }, + ) + f.Mux.HandleFunc("/api/rest/public/project/"+pushProjectID+"/env-file", func(w http.ResponseWriter, r *http.Request) { + s.lock.Lock() + defer s.lock.Unlock() + reveal := r.URL.Query().Get("reveal") + s.envFileReveal = append(s.envFileReveal, reveal) + if reveal != "true" { + writeJSON(w, http.StatusOK, map[string]any{"envFile": "SECRET=*****\n"}) + return + } + if !s.inSudo[bearer(r)] { + writeJSON(w, http.StatusForbidden, apiErrorBody("notInSudoMode")) + return + } + writeJSON(w, http.StatusOK, map[string]any{"envFile": "SECRET=shh\n"}) + }) +} + +func bearer(r *http.Request) string { + return r.Header.Get("Authorization")[len("Bearer "):] +} + +func (f *fixture) seedLoginSession() { + f.seedLoginSessionExpiringIn(6 * time.Hour) +} + +func (f *fixture) seedLoginSessionExpiringIn(expiresIn time.Duration) { + f.seedData(cliStorage.Data{LoginSession: cliStorage.LoginSession{ + AccessToken: "access-0", + RefreshToken: "refresh-0", + ExpiresAt: time.Now().Add(expiresIn), + }}) +} + +func TestProjectEnv_RevealEnablesSudoForLoginSession(t *testing.T) { + f := newFixture(t) + t.Setenv("CI", "") + sudo := f.handleSudo("APPROVED") + f.handleEnvFile(sudo) + f.seedLoginSession() + + res := f.Run("project", "env", "--project-id", pushProjectID, "--reveal") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Equal(t, "SECRET=shh\n", res.Stdout) + assert.Contains(t, res.Stderr, "DK0D-GF7C") + assert.Contains(t, res.Stderr, "Sudo mode is on.") + assert.Equal(t, []string{"access-0"}, sudo.sudoStarts) + assert.Equal(t, []string{"true", "true"}, sudo.envFileReveal) +} + +func TestProjectEnv_RevealWithTokenWithoutSudo(t *testing.T) { + f := newFixture(t) + t.Setenv("CI", "") + sudo := f.handleSudo("APPROVED") + f.handleEnvFile(sudo) + f.SeedLogin("personal") + + res := f.Run("project", "env", "--project-id", pushProjectID, "--reveal") + + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "This token does not have sudo mode") + assert.Empty(t, res.Stdout) + assert.Empty(t, sudo.sudoStarts) +} + +func TestProjectEnv_RevealWithTokenWithSudo(t *testing.T) { + f := newFixture(t) + sudo := f.handleSudo("APPROVED", "personal") + f.handleEnvFile(sudo) + f.SeedLogin("personal") + + res := f.Run("project", "env", "--project-id", pushProjectID, "--reveal") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Equal(t, "SECRET=shh\n", res.Stdout) + assert.Empty(t, sudo.sudoStarts) +} + +func TestProjectEnv_RevealInCi(t *testing.T) { + f := newFixture(t) + t.Setenv("CI", "true") + sudo := f.handleSudo("APPROVED") + f.handleEnvFile(sudo) + f.seedLoginSession() + + res := f.Run("project", "env", "--project-id", pushProjectID, "--reveal") + + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "Sudo mode cannot be enabled in CI") + assert.Empty(t, sudo.sudoStarts) +} + +func TestProjectEnv_WithoutRevealNeedsNoSudo(t *testing.T) { + f := newFixture(t) + sudo := f.handleSudo("APPROVED") + f.handleEnvFile(sudo) + f.seedLoginSession() + + res := f.Run("project", "env", "--project-id", pushProjectID) + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Equal(t, "SECRET=*****\n", res.Stdout) + assert.Equal(t, []string{"false"}, sudo.envFileReveal) + assert.Empty(t, sudo.sudoStarts) +} + +func TestSudo_ShowsExpiryOfLoginSession(t *testing.T) { + f := newFixture(t) + sudo := f.handleSudo("APPROVED", "access-0") + f.seedLoginSession() + + res := f.Run("sudo") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Contains(t, res.Stderr, "Sudo mode is on for 2h 0m more") + assert.Empty(t, sudo.sudoStarts) +} + +func TestSudo_TokenWithSudoHasNoExpiry(t *testing.T) { + f := newFixture(t) + f.handleSudo("APPROVED", "personal") + f.SeedLogin("personal") + + res := f.Run("sudo") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Contains(t, res.Stderr, "Sudo mode is on.") + assert.NotContains(t, res.Stderr, "more") +} + +func TestSudo_EnablesForLoginSession(t *testing.T) { + f := newFixture(t) + t.Setenv("CI", "") + sudo := f.handleSudo("APPROVED") + f.seedLoginSession() + + res := f.Run("sudo") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.Contains(t, res.Stderr, "Sudo mode is off.") + assert.Contains(t, res.Stderr, "DK0D-GF7C") + assert.Contains(t, res.Stderr, "Sudo mode is on for 2h 0m more") + assert.Equal(t, []string{"access-0"}, sudo.sudoStarts) +} + +func TestSudo_RefreshesSessionCloseToExpiryFirst(t *testing.T) { + f := newFixture(t) + t.Setenv("CI", "") + sudo := f.handleSudo("APPROVED") + refreshes := f.handleRefresh("refresh-0") + f.seedLoginSessionExpiringIn(10 * time.Minute) + + res := f.Run("sudo") + + require.Equalf(t, 0, res.ExitCode, "stderr=%q", res.Stderr) + assert.EqualValues(t, 1, refreshes.Load()) + assert.Equal(t, []string{"access-1"}, sudo.sudoStarts) + assert.Contains(t, res.Stderr, "Sudo mode is on for") +} + +func TestSudo_Denied(t *testing.T) { + f := newFixture(t) + t.Setenv("CI", "") + f.handleSudo("DENIED") + f.seedLoginSession() + + res := f.Run("sudo") + + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "Sudo mode was denied in the browser.") +} + +func TestProjectEnv_RevealStillRefusedAfterApproval(t *testing.T) { + f := newFixture(t) + t.Setenv("CI", "") + sudo := f.handleSudo("APPROVED") + sudo.approvalIgnored = true + f.handleEnvFile(sudo) + f.seedLoginSession() + + res := f.Run("project", "env", "--project-id", pushProjectID, "--reveal") + + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "Sudo mode was approved, but the session is still not in sudo mode") + assert.NotContains(t, res.Stderr, "Sudo mode is on.") +} + +func TestSudo_TokenWithoutSudo(t *testing.T) { + f := newFixture(t) + t.Setenv("CI", "") + sudo := f.handleSudo("APPROVED") + f.SeedLogin("personal") + + res := f.Run("sudo") + + require.NotEqual(t, 0, res.ExitCode) + assert.Contains(t, res.Stderr, "Sudo mode is off.") + assert.Contains(t, res.Stderr, "This token does not have sudo mode") + assert.Empty(t, sudo.sudoStarts) +} + +func TestRemaining(t *testing.T) { + assert.Equal(t, "1m", remaining(10*time.Second)) + assert.Equal(t, "59m", remaining(59*time.Minute)) + assert.Equal(t, "1h 0m", remaining(time.Hour)) + assert.Equal(t, "1h 59m", remaining(119*time.Minute+20*time.Second)) +} diff --git a/src/cmdBuilder/browserLogin.go b/src/cmdBuilder/browserLogin.go new file mode 100644 index 00000000..47e55524 --- /dev/null +++ b/src/cmdBuilder/browserLogin.go @@ -0,0 +1,97 @@ +package cmdBuilder + +import ( + "context" + "os" + "strconv" + "time" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/browser" + "github.com/zeropsio/zcli/src/cliStorage" + "github.com/zeropsio/zcli/src/constants" + "github.com/zeropsio/zcli/src/deviceLogin" + "github.com/zeropsio/zcli/src/errorsx" + "github.com/zeropsio/zcli/src/httpClient" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/loginSession" + "github.com/zeropsio/zcli/src/region" + "github.com/zeropsio/zcli/src/terminal" + "github.com/zeropsio/zcli/src/uxBlock" + "github.com/zeropsio/zcli/src/uxBlock/styles" + "github.com/zeropsio/zcli/src/zeropsRestApiClient" +) + +// BrowserLogin runs the device login against reg and stores the session in place of any stored token. +func BrowserLogin(ctx context.Context, cmdData *GuestCmdData, reg region.Item) error { + interactive := terminal.IsInteractive() + if !interactive && isCI() { + return errorsx.NewUserError(i18n.T(i18n.LoginBrowserInCi, constants.CliTokenEnvVar), nil) + } + + auth, err := deviceLogin.Run(ctx, zeropsRestApiClient.NewClient(reg.Address), deviceLoginConfig(cmdData.UxBlocks, interactive)) + if err != nil { + return err + } + session, err := loginSession.FromAuth(auth.Auth) + if err != nil { + return err + } + + _, err = cmdData.CliStorage.Update(func(data cliStorage.Data) cliStorage.Data { + data.Token = "" + data.LoginSession = session + data.RegionData = reg + return data + }) + if err != nil { + return err + } + + fullName, email := "", "" + if auth.User != nil { + fullName, email = auth.User.FullName.Native(), auth.User.Email.Native() + } + cmdData.UxBlocks.PrintInfo(styles.SuccessLine(i18n.T(i18n.LoginSuccess, fullName, email))) + return nil +} + +func deviceLoginConfig(uxBlocks uxBlock.UxBlocks, interactive bool) deviceLogin.Config { + config := deviceLogin.Config{ + AppCode: constants.DeviceLoginAppCode, + Interactive: interactive, + UxBlocks: uxBlocks, + } + // Scripts and agents without a terminal pass the printed URL on, a browser window there would be unexpected. + if interactive { + config.OpenBrowser = browser.Open + } + return config +} + +// isCI follows the CI env variable convention of GitHub Actions, GitLab CI, CircleCI, Bitbucket, Buildkite and Travis. +func isCI() bool { + value := os.Getenv("CI") + if value == "" { + return false + } + isSet, err := strconv.ParseBool(value) + return err != nil || isSet +} + +// defaultLoginRegion is the region for a login that a command other than `zcli login` starts. +func defaultLoginRegion(ctx context.Context, stored region.Item) (region.Item, error) { + if stored.Address != "" { + return stored, nil + } + regions, err := region.New(httpClient.New(ctx, httpClient.Config{HttpTimeout: time.Minute})).RetrieveAllFromURL(ctx, constants.DefaultRegionUrl) + if err != nil { + return region.Item{}, err + } + for _, reg := range regions { + if reg.IsDefault { + return reg, nil + } + } + return region.Item{}, errors.New("no default region found") +} diff --git a/src/cmdBuilder/createRunFunc.go b/src/cmdBuilder/createRunFunc.go index c676acc0..51361571 100644 --- a/src/cmdBuilder/createRunFunc.go +++ b/src/cmdBuilder/createRunFunc.go @@ -12,12 +12,17 @@ import ( "github.com/zeropsio/zcli/src/cliStorage" "github.com/zeropsio/zcli/src/constants" "github.com/zeropsio/zcli/src/entity" + "github.com/zeropsio/zcli/src/errorsx" "github.com/zeropsio/zcli/src/flagParams" "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/loginSession" "github.com/zeropsio/zcli/src/optional" "github.com/zeropsio/zcli/src/printer" + "github.com/zeropsio/zcli/src/region" + "github.com/zeropsio/zcli/src/terminal" "github.com/zeropsio/zcli/src/upgrade" "github.com/zeropsio/zcli/src/uxBlock" + "github.com/zeropsio/zcli/src/uxBlock/models/prompt" "github.com/zeropsio/zcli/src/uxHelpers" "github.com/zeropsio/zcli/src/zeropsRestApiClient" "github.com/zeropsio/zerops-go/types/uuid" @@ -45,6 +50,8 @@ type LoggedUserCmdData struct { ProjectSelector func(context.Context, *LoggedUserCmdData, ...uxHelpers.ProjectSelectorOption) (entity.Project, bool, error) VpnKeys map[uuid.ProjectId]entity.VpnKey + + tokenSource zeropsRestApiClient.TokenSource } func createCmdRunFunc( @@ -86,16 +93,17 @@ func createCmdRunFunc( } storedData := cliStorage.Data() - - token := storedData.Token - // An empty value (e.g. `export ZEROPS_TOKEN=` in a shell, or a - // process inheriting an explicitly cleared env) must not overwrite - // the token persisted by `zcli login` — otherwise the user appears - // silently logged out. - if envToken := os.Getenv(constants.CliTokenEnvVar); envToken != "" { - token = envToken + if !hasToken(storedData) && cmd.guestRunFunc == nil && terminal.IsInteractive() { + loggedIn, err := offerBrowserLogin(ctx, guestCmdData, storedData.RegionData) + if err != nil { + return err + } + if loggedIn { + storedData = cliStorage.Data() + } } - if token == "" { + + if !hasToken(storedData) { if cmd.guestRunFunc != nil { return cmd.guestRunFunc(ctx, guestCmdData) } @@ -116,16 +124,54 @@ func createCmdRunFunc( if host == "" { host = constants.DefaultRegion } - cmdData.RestApiClient = zeropsRestApiClient.NewAuthorizedClient(token, host) - - if cmd.scopeLevel != nil { - if err := cmd.scopeLevel.LoadSelectedScope(ctx, cmd, cmdData); err != nil { - return err + source := tokenSource(storedData, cliStorage, host) + cmdData.tokenSource = source + cmdData.RestApiClient = zeropsRestApiClient.NewTokenSourceClient(source, host) + + err = func() error { + if cmd.scopeLevel != nil { + if err := cmd.scopeLevel.LoadSelectedScope(ctx, cmd, cmdData); err != nil { + return err + } } + return cmd.loggedUserRunFunc(ctx, cmdData) + }() + // The root command reports API errors in its output and succeeds, so an expired session is checked even without err. + if session, ok := source.(*loginSession.Source); ok && session.Expired() { + return errorsx.NewUserError(i18n.T(i18n.LoginSessionExpired), err) } + return err + } +} - return cmd.loggedUserRunFunc(ctx, cmdData) +func EnvToken() string { + return os.Getenv(constants.CliTokenEnvVar) +} + +func hasToken(data cliStorage.Data) bool { + return EnvToken() != "" || data.Token != "" || data.LoginSession.IsSet() +} + +func tokenSource(data cliStorage.Data, storage *cliStorage.Handler, host string) zeropsRestApiClient.TokenSource { + if token := EnvToken(); token != "" { + return zeropsRestApiClient.StaticToken(token) + } + if data.Token != "" { + return zeropsRestApiClient.StaticToken(data.Token) + } + return loginSession.New(storage, zeropsRestApiClient.NewClient(host)) +} + +func offerBrowserLogin(ctx context.Context, cmdData *GuestCmdData, storedRegion region.Item) (bool, error) { + confirmed, err := uxHelpers.YesNoPrompt(ctx, i18n.T(i18n.LoginBrowserPrompt), prompt.WithCursorPosition(1)) + if err != nil || !confirmed { + return false, err + } + reg, err := defaultLoginRegion(ctx, storedRegion) + if err != nil { + return false, err } + return true, BrowserLogin(ctx, cmdData, reg) } func convertArgs(cmd *Cmd, args []string) (map[string][]string, error) { diff --git a/src/cmdBuilder/sudo.go b/src/cmdBuilder/sudo.go new file mode 100644 index 00000000..104d912f --- /dev/null +++ b/src/cmdBuilder/sudo.go @@ -0,0 +1,83 @@ +package cmdBuilder + +import ( + "context" + "time" + + "github.com/zeropsio/zcli/src/deviceLogin" + "github.com/zeropsio/zcli/src/errorsx" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/loginSession" + "github.com/zeropsio/zcli/src/terminal" + "github.com/zeropsio/zcli/src/uxBlock/models/prompt" + "github.com/zeropsio/zcli/src/uxBlock/styles" + "github.com/zeropsio/zcli/src/uxHelpers" + "github.com/zeropsio/zerops-go/apiError" + "github.com/zeropsio/zerops-go/dto/input/body" + "github.com/zeropsio/zerops-go/errorCode" + "github.com/zeropsio/zerops-go/sdk" +) + +// WithSudo runs call once more after it enables sudo mode, if the API refused call for missing sudo mode. +// call must be safe to repeat, so it should wrap only the request that needs sudo mode. +func (d *LoggedUserCmdData) WithSudo(ctx context.Context, call func() error) error { + err := call() + if !apiError.HasErrorCode(err, errorCode.NotInSudoMode) { + return err + } + if !d.IsLoginSession() { + return errorsx.NewUserError(i18n.T(i18n.SudoTokenWithout), err) + } + if terminal.IsInteractive() { + confirmed, promptErr := uxHelpers.YesNoPrompt(ctx, i18n.T(i18n.SudoRequiredPrompt), prompt.WithCursorPosition(1)) + if promptErr != nil { + return promptErr + } + if !confirmed { + return errorsx.NewUserError(i18n.T(i18n.SudoRequired), err) + } + } + if err := d.EnableSudo(ctx); err != nil { + return err + } + err = call() + if apiError.HasErrorCode(err, errorCode.NotInSudoMode) { + return errorsx.NewUserError(i18n.T(i18n.SudoNotApplied), err) + } + d.UxBlocks.PrintInfo(styles.SuccessLine(i18n.T(i18n.SudoOn))) + return err +} + +// IsLoginSession reports whether the token comes from a browser login. Only such a session can enable sudo mode, a token has it set in the GUI. +func (d *LoggedUserCmdData) IsLoginSession() bool { + _, ok := d.tokenSource.(*loginSession.Source) + return ok +} + +// EnableSudo asks for an approval in the browser, which switches the login session to sudo mode. +func (d *LoggedUserCmdData) EnableSudo(ctx context.Context) error { + interactive := terminal.IsInteractive() + if !interactive && isCI() { + return errorsx.NewUserError(i18n.T(i18n.SudoInCi), nil) + } + var api deviceLogin.SudoApi = d.RestApiClient + if session, ok := d.tokenSource.(*loginSession.Source); ok { + api = sessionSudoApi{SudoApi: d.RestApiClient, session: session} + } + return deviceLogin.RunSudo(ctx, api, deviceLoginConfig(d.UxBlocks, interactive)) +} + +// sudoRefreshMargin covers the 5 minutes of a sudo request and the 5 minute refresh margin of loginSession.Source.Token, with time to spare. +const sudoRefreshMargin = 15 * time.Minute + +// sessionSudoApi refreshes the session before each sudo request. +// The server binds the request to the access token, and a refresh before the approval would delete that token. +type sessionSudoApi struct { + deviceLogin.SudoApi + session *loginSession.Source +} + +func (a sessionSudoApi) PostAuthDeviceLoginSudo(ctx context.Context, in body.DeviceLoginInitiate) (sdk.PostAuthDeviceLoginSudoResponse, error) { + a.session.RefreshAhead(ctx, sudoRefreshMargin) + return a.SudoApi.PostAuthDeviceLoginSudo(ctx, in) +} diff --git a/src/constants/zerops.go b/src/constants/zerops.go index d694db46..94b11f37 100644 --- a/src/constants/zerops.go +++ b/src/constants/zerops.go @@ -14,6 +14,7 @@ import ( const ( DefaultRegionUrl = "https://api.app-prg1.zerops.io/api/rest/public/region/zcli" DefaultRegion = "api.app-prg1.zerops.io" + DeviceLoginAppCode = "zcli" ZeropsDir = "zerops" ZeropsLogFile = "zerops.log" WgConfigFile = "zerops.conf" diff --git a/src/deviceLogin/deviceLogin.go b/src/deviceLogin/deviceLogin.go new file mode 100644 index 00000000..f3855420 --- /dev/null +++ b/src/deviceLogin/deviceLogin.go @@ -0,0 +1,116 @@ +// Package deviceLogin runs the browser approval of a public app: initiate a request, show the user code and poll until the user approves it. +// It serves both a login and the switch of an existing login session to sudo mode. +package deviceLogin + +import ( + "context" + "time" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/errorsx" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/uxBlock" + "github.com/zeropsio/zerops-go/dto/input/body" + "github.com/zeropsio/zerops-go/dto/output" + "github.com/zeropsio/zerops-go/sdk" +) + +type Checker interface { + PostAuthDeviceLoginCheck(ctx context.Context, in body.DeviceLoginCheck) (sdk.PostAuthDeviceLoginCheckResponse, error) +} + +// Api is satisfied by an unauthenticated *zeropsRestApiClient.Handler. +type Api interface { + PostAuthDeviceLogin(ctx context.Context, in body.DeviceLoginInitiate) (sdk.PostAuthDeviceLoginResponse, error) + Checker +} + +// SudoApi is satisfied by an authorized *zeropsRestApiClient.Handler, the server binds the sudo request to the session of its token. +type SudoApi interface { + PostAuthDeviceLoginSudo(ctx context.Context, in body.DeviceLoginInitiate) (sdk.PostAuthDeviceLoginSudoResponse, error) + Checker +} + +type flow struct { + start func(ctx context.Context, in body.DeviceLoginInitiate) (output.DeviceLoginInitiate, error) + checker Checker + // sudo is approved without auth, because the approval switches the session that started the request to sudo mode. + sudo bool +} + +type initiateResponse interface { + Output() (output.DeviceLoginInitiate, error) +} + +func starter[R initiateResponse](call func(context.Context, body.DeviceLoginInitiate) (R, error)) func(context.Context, body.DeviceLoginInitiate) (output.DeviceLoginInitiate, error) { + return func(ctx context.Context, in body.DeviceLoginInitiate) (output.DeviceLoginInitiate, error) { + resp, err := call(ctx, in) + if err != nil { + return output.DeviceLoginInitiate{}, err + } + return resp.Output() + } +} + +type Config struct { + AppCode string + // Interactive runs the Bubble Tea TUI, otherwise everything goes through UxBlocks as plain lines. + Interactive bool + // OpenBrowser is best effort, nil skips the attempt. On an error the user is told to open the printed link. + OpenBrowser func(url string) error + UxBlocks uxBlock.UxBlocks + + // unit is the length of one server second, tests shorten it to keep polling and the countdown fast. + unit time.Duration +} + +var ( + ErrDenied = errors.New("device login denied") + ErrExpired = errors.New("device login expired") +) + +// Run returns the AuthLogin of an approved request. +// Denied requests fail with ErrDenied, a request that the server no longer knows fails with ErrExpired in non-interactive mode +// and offers a new code in interactive mode. Ctrl+C in the TUI fails with models.ErrCtrlC. +func Run(ctx context.Context, api Api, cfg Config) (output.AuthLogin, error) { + return run(ctx, flow{start: starter(api.PostAuthDeviceLogin), checker: api}, cfg) +} + +// RunSudo switches the session of the api token to sudo mode. It fails like Run. +func RunSudo(ctx context.Context, api SudoApi, cfg Config) error { + _, err := run(ctx, flow{start: starter(api.PostAuthDeviceLoginSudo), checker: api, sudo: true}, cfg) + return err +} + +func run(ctx context.Context, f flow, cfg Config) (output.AuthLogin, error) { + if cfg.unit <= 0 { + cfg.unit = time.Second + } + if cfg.Interactive { + return runInteractive(ctx, f, cfg) + } + return runPlain(ctx, f, cfg) +} + +func deniedError(f flow) error { + if f.sudo { + return errorsx.NewUserError(i18n.T(i18n.DeviceLoginSudoDenied), ErrDenied) + } + return errorsx.NewUserError(i18n.T(i18n.DeviceLoginDenied), ErrDenied) +} + +func expiredError() error { + return errorsx.NewUserError(i18n.T(i18n.DeviceLoginExpiredError), ErrExpired) +} + +// openBrowser returns the line that tells the user whether the browser opened or they have to open the printed link themselves. +func openBrowser(cfg Config, url string) string { + if cfg.OpenBrowser == nil { + return i18n.T(i18n.DeviceLoginOpenLink) + } + if err := cfg.OpenBrowser(url); err != nil { + cfg.UxBlocks.LogDebug("device login: open browser: " + err.Error()) + return i18n.T(i18n.DeviceLoginOpenLink) + } + return i18n.T(i18n.DeviceLoginBrowserOpened) +} diff --git a/src/deviceLogin/deviceLogin_test.go b/src/deviceLogin/deviceLogin_test.go new file mode 100644 index 00000000..f011b74d --- /dev/null +++ b/src/deviceLogin/deviceLogin_test.go @@ -0,0 +1,249 @@ +package deviceLogin + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/charmbracelet/x/exp/teatest" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zeropsio/zcli/src/errorsx" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zerops-go/apiError" + "github.com/zeropsio/zerops-go/errorCode" +) + +func TestPlain_ApprovedAfterPending(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{pending(), pending(), approved()}) + rec := &recorder{} + + auth, err := Run(t.Context(), srv.api(), testConfig(t, rec, false)) + require.NoError(t, err) + assert.Equal(t, testToken, auth.Auth.AccessToken.Native()) + + initiates, checks := srv.calls() + assert.Equal(t, 1, initiates) + assert.Equal(t, 3, checks) + srv.requireSecretsSent(testSecret, testSecret, testSecret) + + lines := rec.printed() + require.Len(t, lines, 4) + assert.Contains(t, lines[0], i18n.T(i18n.DeviceLoginConfirmCode)) + assert.Contains(t, lines[1], testCode) + assert.Contains(t, lines[2], testUrl) + assert.Contains(t, lines[3], i18n.T(i18n.DeviceLoginOpenLink)) +} + +func TestPlain_Denied(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{denied()}) + rec := &recorder{} + + _, err := Run(t.Context(), srv.api(), testConfig(t, rec, false)) + require.ErrorIs(t, err, ErrDenied) + require.NotNil(t, errorsx.AsUserError(err)) + assert.Equal(t, i18n.T(i18n.DeviceLoginDenied), err.Error()) +} + +func TestPlain_Expired(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{pending(), notFound()}) + rec := &recorder{} + + _, err := Run(t.Context(), srv.api(), testConfig(t, rec, false)) + require.ErrorIs(t, err, ErrExpired) + require.NotNil(t, errorsx.AsUserError(err)) + assert.Equal(t, i18n.T(i18n.DeviceLoginExpiredError), err.Error()) + + _, checks := srv.calls() + assert.Equal(t, 2, checks) +} + +func TestPlain_TransientErrorsThenApproved(t *testing.T) { + htmlGateway := reply{status: http.StatusBadGateway, body: "bad gateway"} + srv := newFakeServer( + t, + []reply{defaultInitiate()}, + []reply{aborted(), apiErrorReply(http.StatusInternalServerError, "internalServerError"), htmlGateway, approved()}, + ) + rec := &recorder{} + + auth, err := Run(t.Context(), srv.api(), testConfig(t, rec, false)) + require.NoError(t, err) + assert.Equal(t, testToken, auth.Auth.AccessToken.Native()) + + _, checks := srv.calls() + assert.Equal(t, 4, checks) + assert.Len(t, rec.debugged(), 3) +} + +func TestPlain_CheckTimeoutThenApproved(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{delayed(pending(), 2*time.Second), approved()}) + rec := &recorder{} + cfg := testConfig(t, rec, false) + + req, err := initiate(t.Context(), srv.loginFlow(), cfg) + require.NoError(t, err) + assert.Equal(t, 15*time.Millisecond, req.checkTimeout) + + start := time.Now() + auth, err := Run(t.Context(), srv.api(), cfg) + require.NoError(t, err) + assert.Equal(t, testToken, auth.Auth.AccessToken.Native()) + assert.Less(t, time.Since(start), time.Second) + + _, checks := srv.calls() + assert.Equal(t, 2, checks) + require.Len(t, rec.debugged(), 1) + assert.Contains(t, rec.debugged()[0], context.DeadlineExceeded.Error()) +} + +func TestPlain_ClientErrorOnCheck(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{apiErrorReply(http.StatusBadRequest, "deviceLoginAppInvalid")}) + rec := &recorder{} + + _, err := Run(t.Context(), srv.api(), testConfig(t, rec, false)) + require.Error(t, err) + assert.True(t, apiError.HasErrorCode(err, errorCode.DeviceLoginAppInvalid)) + assert.False(t, errors.Is(err, ErrDenied)) + assert.False(t, errors.Is(err, ErrExpired)) + + _, checks := srv.calls() + assert.Equal(t, 1, checks) +} + +func TestPlain_InitiateApiError(t *testing.T) { + srv := newFakeServer(t, []reply{apiErrorReply(http.StatusBadRequest, "deviceLoginAppInvalid")}, nil) + rec := &recorder{} + + _, err := Run(t.Context(), srv.api(), testConfig(t, rec, false)) + require.Error(t, err) + assert.True(t, apiError.HasErrorCode(err, errorCode.DeviceLoginAppInvalid)) + + _, checks := srv.calls() + assert.Equal(t, 0, checks) + assert.Empty(t, rec.printed()) +} + +func TestPlain_IntervalGuard(t *testing.T) { + srv := newFakeServer(t, []reply{initiateReply(testCode, testUrl, testSecret, 0)}, []reply{pending(), approved()}) + rec := &recorder{} + cfg := testConfig(t, rec, false) + + req, err := initiate(t.Context(), srv.loginFlow(), cfg) + require.NoError(t, err) + assert.Equal(t, cfg.unit, req.interval) + + start := time.Now() + _, err = Run(t.Context(), srv.api(), cfg) + require.NoError(t, err) + assert.Less(t, time.Since(start), 5*time.Second) +} + +func TestPlain_ContextCanceled(t *testing.T) { + srv := newFakeServer(t, []reply{initiateReply(testCode, testUrl, testSecret, 1000)}, []reply{pending()}) + rec := &recorder{} + ctx, cancel := context.WithCancel(t.Context()) + + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + start := time.Now() + _, err := Run(ctx, srv.api(), testConfig(t, rec, false)) + require.ErrorIs(t, err, context.Canceled) + assert.Less(t, time.Since(start), 5*time.Second) +} + +func TestPlain_ContextCanceledDuringCheck(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{pending()}) + rec := &recorder{} + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + req, err := initiate(t.Context(), srv.loginFlow(), testConfig(t, rec, false)) + require.NoError(t, err) + + res := check(ctx, srv.loginFlow(), req) + assert.Equal(t, pollFailed, res.outcome) + require.ErrorIs(t, res.err, context.Canceled) +} + +func TestPlain_ApprovedWithoutAuth(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{approvedWithoutAuth()}) + rec := &recorder{} + + _, err := Run(t.Context(), srv.api(), testConfig(t, rec, false)) + require.Error(t, err) + assert.False(t, errors.Is(err, ErrDenied)) + assert.False(t, errors.Is(err, ErrExpired)) + assert.Contains(t, err.Error(), "without auth") +} + +func TestPlain_OpenBrowser(t *testing.T) { + t.Run("opened", func(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{approved()}) + rec := &recorder{} + cfg := testConfig(t, rec, false) + var opened string + cfg.OpenBrowser = func(url string) error { + opened = url + return nil + } + + _, err := Run(t.Context(), srv.api(), cfg) + require.NoError(t, err) + assert.Equal(t, testUrl, opened) + assert.Contains(t, rec.printed()[3], i18n.T(i18n.DeviceLoginBrowserOpened)) + }) + + t.Run("failed", func(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{approved()}) + rec := &recorder{} + cfg := testConfig(t, rec, false) + cfg.OpenBrowser = func(string) error { + return errors.New("no browser") + } + + _, err := Run(t.Context(), srv.api(), cfg) + require.NoError(t, err) + assert.Contains(t, rec.printed()[3], i18n.T(i18n.DeviceLoginOpenLink)) + require.Len(t, rec.debugged(), 1) + assert.Contains(t, rec.debugged()[0], "no browser") + }) +} + +func TestRunSudo_ApprovedWithoutAuth(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{pending(), approvedWithoutAuth()}) + rec := &recorder{} + + err := RunSudo(t.Context(), srv.api(), testConfig(t, rec, false)) + require.NoError(t, err) + + assert.Equal(t, []string{"/api/rest/public/auth/device-login/sudo"}, srv.paths()) + srv.requireSecretsSent(testSecret, testSecret) + assert.Contains(t, rec.printed()[1], testCode) +} + +func TestRunSudo_Denied(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{denied()}) + rec := &recorder{} + + err := RunSudo(t.Context(), srv.api(), testConfig(t, rec, false)) + require.ErrorIs(t, err, ErrDenied) + assert.Equal(t, i18n.T(i18n.DeviceLoginSudoDenied), err.Error()) +} + +func TestRunSudo_Interactive(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{approvedWithoutAuth()}) + rec := &recorder{} + api := srv.api() + m := newModel(t.Context(), flow{start: starter(api.PostAuthDeviceLoginSudo), checker: api, sudo: true}, testConfig(t, rec, true)) + t.Cleanup(m.cancel) + tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(testTermWidth, testTermHeight)) + + _, err := finalResult(t, tm) + require.NoError(t, err) + assert.Equal(t, []string{"/api/rest/public/auth/device-login/sudo"}, srv.paths()) +} diff --git a/src/deviceLogin/fake_test.go b/src/deviceLogin/fake_test.go new file mode 100644 index 00000000..406174c9 --- /dev/null +++ b/src/deviceLogin/fake_test.go @@ -0,0 +1,248 @@ +package deviceLogin + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + "github.com/zeropsio/zcli/src/uxBlock/mocks" + "github.com/zeropsio/zcli/src/uxBlock/styles" + "github.com/zeropsio/zerops-go/sdk" + "github.com/zeropsio/zerops-go/sdkBase" +) + +const ( + testCode = "DK0D-GF7C" + testCode2 = "AB12-CD34" + testUrl = "https://tatami.devel.zerops.dev/authorize-login?request=MY9Z7FZkTUyuTacCH9RoIw" + testUrl2 = "https://tatami.devel.zerops.dev/authorize-login?request=Y2JfkQbSRZOfHe5lGmUJoQ" + testRequestId = "MY9Z7FZkTUyuTacCH9RoIw" + testSecret = "poll-secret-one" + testSecret2 = "poll-secret-two" + testToken = "access-token" +) + +// reply is one scripted HTTP answer. abort drops the connection, which the SDK reports as a transport error, delay holds the answer back. +type reply struct { + status int + body string + abort bool + delay time.Duration +} + +func delayed(rep reply, delay time.Duration) reply { + rep.delay = delay + return rep +} + +func initiateReply(code string, url string, secret string, interval int) reply { + body := fmt.Sprintf( + `{"requestId":%q,"pollSecret":%q,"userCode":%q,"verificationUrl":%q,"expiresIn":%d,"interval":%d}`, + testRequestId, secret, code, url, 300, interval, + ) + return reply{status: http.StatusOK, body: body} +} + +func defaultInitiate() reply { + return initiateReply(testCode, testUrl, testSecret, 5) +} + +func pending() reply { + return reply{status: http.StatusOK, body: `{"status":"PENDING","auth":null}`} +} + +func approved() reply { + return reply{status: http.StatusOK, body: fmt.Sprintf(`{"status":"APPROVED","auth":{"user":null,"auth":{"accessToken":%q,"refreshToken":"refresh-token"}}}`, testToken)} +} + +func approvedWithoutAuth() reply { + return reply{status: http.StatusOK, body: `{"status":"APPROVED","auth":null}`} +} + +func denied() reply { + return reply{status: http.StatusOK, body: `{"status":"DENIED","auth":null}`} +} + +func apiErrorReply(status int, code string) reply { + return reply{status: status, body: fmt.Sprintf(`{"error":{"code":%q,"message":"test error"}}`, code)} +} + +func notFound() reply { + return apiErrorReply(http.StatusNotFound, "deviceLoginNotFound") +} + +func aborted() reply { + return reply{abort: true} +} + +// fakeServer answers the device login endpoints from scripted queues. The last reply of a queue repeats. +type fakeServer struct { + t *testing.T + srv *httptest.Server + + mu sync.Mutex + initiates []reply + checks []reply + initiateCalls int + checkCalls int + // initiatePaths records the endpoint of every initiate, the login and the sudo flow share the scripted replies. + initiatePaths []string + // sentSecrets records the pollSecret of every check so tests can compare it without printing it. + sentSecrets []string +} + +func newFakeServer(t *testing.T, initiates []reply, checks []reply) *fakeServer { + t.Helper() + f := &fakeServer{t: t, initiates: initiates, checks: checks} + f.srv = httptest.NewServer(http.HandlerFunc(f.handle)) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeServer) api() sdk.Handler { + return sdk.New(sdkBase.DefaultConfig(sdkBase.WithCustomEndpoint(f.srv.URL)), f.srv.Client()) +} + +func (f *fakeServer) loginFlow() flow { + api := f.api() + return flow{start: starter(api.PostAuthDeviceLogin), checker: api} +} + +func (f *fakeServer) handle(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + var rep reply + switch r.URL.Path { + case "/api/rest/public/auth/device-login", "/api/rest/public/auth/device-login/sudo": + f.initiateCalls++ + f.initiatePaths = append(f.initiatePaths, r.URL.Path) + rep = next(&f.initiates) + case "/api/rest/public/auth/device-login/check": + f.checkCalls++ + var in struct { + RequestId string `json:"requestId"` + PollSecret string `json:"pollSecret"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + f.mu.Unlock() + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + f.sentSecrets = append(f.sentSecrets, in.PollSecret) + rep = next(&f.checks) + default: + f.mu.Unlock() + http.NotFound(w, r) + return + } + f.mu.Unlock() + + if rep.delay > 0 { + select { + case <-time.After(rep.delay): + case <-r.Context().Done(): + return + } + } + if rep.abort { + panic(http.ErrAbortHandler) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(rep.status) + _, _ = w.Write([]byte(rep.body)) +} + +func next(queue *[]reply) reply { + if len(*queue) == 0 { + return reply{status: http.StatusInternalServerError, body: `{"error":{"code":"testQueueEmpty","message":"no reply scripted"}}`} + } + rep := (*queue)[0] + if len(*queue) > 1 { + *queue = (*queue)[1:] + } + return rep +} + +func (f *fakeServer) setChecks(checks ...reply) { + f.mu.Lock() + defer f.mu.Unlock() + f.checks = checks +} + +func (f *fakeServer) calls() (initiates int, checks int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.initiateCalls, f.checkCalls +} + +func (f *fakeServer) paths() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.initiatePaths...) +} + +func (f *fakeServer) requireSecretsSent(secrets ...string) { + f.t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + require.Len(f.t, f.sentSecrets, len(secrets), "unexpected number of checks") + for i, secret := range secrets { + //nolint:testifylint // Why: a failing Equal would print the pollSecret + require.True(f.t, f.sentSecrets[i] == secret, "check %d sent a different pollSecret", i) + } +} + +// recorder captures what the flow prints through UxBlocks. +type recorder struct { + mu sync.Mutex + lines []string + debug []string +} + +func (r *recorder) uxBlocks(t *testing.T) *mocks.MockUxBlocks { + t.Helper() + ux := mocks.NewMockUxBlocks(gomock.NewController(t)) + ux.EXPECT().PrintInfoText(gomock.Any()).AnyTimes().Do(func(line string) { + r.mu.Lock() + defer r.mu.Unlock() + r.lines = append(r.lines, line) + }) + ux.EXPECT().PrintInfo(gomock.Any()).AnyTimes().Do(func(line styles.Line) { + r.mu.Lock() + defer r.mu.Unlock() + r.lines = append(r.lines, line.String()) + }) + ux.EXPECT().LogDebug(gomock.Any()).AnyTimes().Do(func(message string) { + r.mu.Lock() + defer r.mu.Unlock() + r.debug = append(r.debug, message) + }) + return ux +} + +func (r *recorder) printed() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.lines...) +} + +func (r *recorder) debugged() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.debug...) +} + +func testConfig(t *testing.T, rec *recorder, interactive bool) Config { + t.Helper() + return Config{ + AppCode: "zcli", + Interactive: interactive, + UxBlocks: rec.uxBlocks(t), + unit: time.Millisecond, + } +} diff --git a/src/deviceLogin/model.go b/src/deviceLogin/model.go new file mode 100644 index 00000000..29bb0d7c --- /dev/null +++ b/src/deviceLogin/model.go @@ -0,0 +1,270 @@ +package deviceLogin + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/uxBlock/models" + "github.com/zeropsio/zcli/src/uxBlock/styles" + "github.com/zeropsio/zerops-go/dto/output" +) + +func runInteractive(ctx context.Context, f flow, cfg Config) (output.AuthLogin, error) { + m := newModel(ctx, f, cfg) + defer m.cancel() + final, err := tea.NewProgram(m, tea.WithoutSignalHandler(), tea.WithContext(ctx)).Run() + if err != nil { + if ctx.Err() != nil { + return output.AuthLogin{}, ctx.Err() + } + return output.AuthLogin{}, err + } + return result(final) +} + +func result(final tea.Model) (output.AuthLogin, error) { + m, ok := final.(*model) + if !ok { + return output.AuthLogin{}, models.ErrInvalidModelType + } + if m.err != nil { + return output.AuthLogin{}, m.err + } + return m.auth, nil +} + +type state int + +const ( + stateInitiating state = iota + stateWaiting + stateExpired + stateDone +) + +type keyMap struct { + quit key.Binding + newCode key.Binding +} + +// The code box and the URL go to scrollback with tea.Println. The renderer truncates live lines to the terminal width, which would cut the URL. +// The live area holds only the spinner line or the expired prompt. +type model struct { + //nolint:containedctx + ctx context.Context + cancel context.CancelFunc + flow flow + cfg Config + keyMap keyMap + + spinner spinner.Model + state state + // generation tags the messages of one request, so messages of a request replaced after an expiry are dropped. + generation int + req request + deadline time.Time + + auth output.AuthLogin + err error + quit bool +} + +type initiatedMsg struct { + generation int + req request + err error +} + +type browserMsg struct { + generation int + line string +} + +type pollTickMsg struct { + generation int +} + +type pollMsg struct { + generation int + result pollResult +} + +func newModel(ctx context.Context, f flow, cfg Config) *model { + ctx, cancel := context.WithCancel(ctx) + return &model{ + ctx: ctx, + cancel: cancel, + flow: f, + cfg: cfg, + keyMap: keyMap{ + quit: key.NewBinding(key.WithKeys("ctrl+c")), + newCode: key.NewBinding(key.WithKeys("enter")), + }, + spinner: spinner.New(spinner.WithSpinner(spinner.MiniDot)), + } +} + +func (m *model) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, m.initiateCmd()) +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.quit { + m.cancel() + return m, tea.Quit + } + + switch msg := msg.(type) { + case tea.KeyMsg: + return m.updateKey(msg) + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case initiatedMsg: + if msg.generation != m.generation { + return m, nil + } + return m.updateInitiated(msg) + case browserMsg: + if msg.generation != m.generation { + return m, nil + } + return m, tea.Println(msg.line) + case pollTickMsg: + if msg.generation != m.generation || m.state != stateWaiting { + return m, nil + } + return m, m.checkCmd() + case pollMsg: + if msg.generation != m.generation || m.state != stateWaiting { + return m, nil + } + return m.updatePoll(msg.result) + } + return m, nil +} + +func (m *model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keyMap.quit): + return m.finish(models.ErrCtrlC) + case key.Matches(msg, m.keyMap.newCode) && m.state == stateExpired: + m.generation++ + m.state = stateInitiating + return m, tea.Batch(tea.Println(i18n.T(i18n.DeviceLoginPreviousExpired)), m.initiateCmd()) + } + return m, nil +} + +func (m *model) updateInitiated(msg initiatedMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + return m.finish(msg.err) + } + m.req = msg.req + m.deadline = time.Now().Add(msg.req.expiresIn) + m.state = stateWaiting + return m, tea.Batch(tea.Println(requestText(msg.req)), m.openBrowserCmd(), m.pollTickCmd()) +} + +func (m *model) updatePoll(res pollResult) (tea.Model, tea.Cmd) { + switch res.outcome { + case pollPending: + return m, m.pollTickCmd() + case pollRetry: + m.cfg.UxBlocks.LogDebug("device login: check failed, retrying: " + res.err.Error()) + return m, m.pollTickCmd() + case pollApproved: + m.auth = res.auth + return m.finish(nil) + case pollDenied: + return m.finish(deniedError(m.flow)) + case pollExpired: + m.state = stateExpired + return m, nil + case pollFailed: + return m.finish(res.err) + } + return m.finish(errors.Errorf("unexpected poll outcome %d", res.outcome)) +} + +// finish records the outcome and renders one more frame before the next Update quits, as the other root models do. +func (m *model) finish(err error) (tea.Model, tea.Cmd) { + m.err = err + m.state = stateDone + m.quit = true + return m, models.Noop +} + +func (m *model) initiateCmd() tea.Cmd { + ctx, f, cfg, generation := m.ctx, m.flow, m.cfg, m.generation + return func() tea.Msg { + req, err := initiate(ctx, f, cfg) + return initiatedMsg{generation: generation, req: req, err: err} + } +} + +func (m *model) openBrowserCmd() tea.Cmd { + cfg, url, generation := m.cfg, m.req.url, m.generation + return func() tea.Msg { + return browserMsg{generation: generation, line: openBrowser(cfg, url)} + } +} + +func (m *model) pollTickCmd() tea.Cmd { + generation := m.generation + return tea.Tick(m.req.interval, func(time.Time) tea.Msg { + return pollTickMsg{generation: generation} + }) +} + +func (m *model) checkCmd() tea.Cmd { + ctx, f, req, generation := m.ctx, m.flow, m.req, m.generation + return func() tea.Msg { + return pollMsg{generation: generation, result: check(ctx, f, req)} + } +} + +func (m *model) View() string { + switch m.state { + case stateWaiting: + return m.spinner.View() + " " + i18n.T(i18n.DeviceLoginWaiting, m.countdown()) + "\n" + case stateExpired: + return i18n.T(i18n.DeviceLoginExpired) + "\n" + case stateInitiating, stateDone: + return "" + } + return "" +} + +func (m *model) countdown() string { + seconds := max(int(time.Until(m.deadline)/m.cfg.unit), 0) + return fmt.Sprintf("%d:%02d", seconds/60, seconds%60) +} + +func requestText(req request) string { + return i18n.T(i18n.DeviceLoginConfirmCode) + "\n" + codeBox(req.userCode) + "\n" + req.url +} + +func codeBox(code string) string { + return styles.DefaultStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(styles.InfoColor). + Padding(0, 2). + Bold(true). + Foreground(styles.SelectColor). + Render(spaced(code)) +} + +func spaced(code string) string { + letters := strings.Split(code, "") + return strings.Join(letters, " ") +} diff --git a/src/deviceLogin/model_test.go b/src/deviceLogin/model_test.go new file mode 100644 index 00000000..0e032d2b --- /dev/null +++ b/src/deviceLogin/model_test.go @@ -0,0 +1,151 @@ +package deviceLogin + +import ( + "bytes" + "io" + "net/http" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/uxBlock/models" + "github.com/zeropsio/zerops-go/apiError" + "github.com/zeropsio/zerops-go/errorCode" +) + +const testTermWidth, testTermHeight = 80, 24 + +func newTestModel(t *testing.T, srv *fakeServer, rec *recorder) (*model, *teatest.TestModel) { + t.Helper() + m := newModel(t.Context(), srv.loginFlow(), testConfig(t, rec, true)) + t.Cleanup(m.cancel) + tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(testTermWidth, testTermHeight)) + return m, tm +} + +func waitForOutput(t *testing.T, tm *teatest.TestModel, want ...string) { + t.Helper() + teatest.WaitFor( + t, + tm.Output(), + func(out []byte) bool { + for _, w := range want { + if !bytes.Contains(out, []byte(w)) { + return false + } + } + return true + }, + teatest.WithDuration(5*time.Second), + ) +} + +func finalResult(t *testing.T, tm *teatest.TestModel) (string, error) { + t.Helper() + tm.WaitFinished(t, teatest.WithFinalTimeout(5*time.Second)) + out, err := io.ReadAll(tm.FinalOutput(t)) + require.NoError(t, err) + _, resErr := result(tm.FinalModel(t)) + return string(out), resErr +} + +func TestModel_RendersCodeAndUrlThenCtrlC(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{pending()}) + rec := &recorder{} + _, tm := newTestModel(t, srv, rec) + + waitForOutput( + t, + tm, + i18n.T(i18n.DeviceLoginConfirmCode), + "D K 0 D - G F 7 C", + "╭", + testUrl, + i18n.T(i18n.DeviceLoginOpenLink), + "Waiting for approval (", + ) + + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) + _, err := finalResult(t, tm) + require.ErrorIs(t, err, models.ErrCtrlC) +} + +func TestModel_Approved(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{pending(), approved()}) + rec := &recorder{} + m, tm := newTestModel(t, srv, rec) + + out, err := finalResult(t, tm) + require.NoError(t, err) + assert.Equal(t, testToken, m.auth.Auth.AccessToken.Native()) + assert.Contains(t, out, "D K 0 D - G F 7 C") + assert.NotContains(t, out, testSecret) + + _, checks := srv.calls() + assert.Equal(t, 2, checks) + srv.requireSecretsSent(testSecret, testSecret) +} + +func TestModel_Denied(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{denied()}) + rec := &recorder{} + _, tm := newTestModel(t, srv, rec) + + _, err := finalResult(t, tm) + require.ErrorIs(t, err, ErrDenied) +} + +func TestModel_InitiateError(t *testing.T) { + srv := newFakeServer(t, []reply{apiErrorReply(http.StatusBadRequest, "deviceLoginAppInvalid")}, nil) + rec := &recorder{} + _, tm := newTestModel(t, srv, rec) + + _, err := finalResult(t, tm) + require.Error(t, err) + assert.True(t, apiError.HasErrorCode(err, errorCode.DeviceLoginAppInvalid)) +} + +func TestModel_ExpiredThenEnterRestarts(t *testing.T) { + srv := newFakeServer( + t, + []reply{defaultInitiate(), initiateReply(testCode2, testUrl2, testSecret2, 5)}, + []reply{notFound(), pending(), approved()}, + ) + rec := &recorder{} + m, tm := newTestModel(t, srv, rec) + + waitForOutput(t, tm, "D K 0 D - G F 7 C", i18n.T(i18n.DeviceLoginExpired)) + _, checks := srv.calls() + assert.Equal(t, 1, checks) + + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + waitForOutput(t, tm, i18n.T(i18n.DeviceLoginPreviousExpired), "A B 1 2 - C D 3 4", testUrl2) + + _, err := finalResult(t, tm) + require.NoError(t, err) + assert.Equal(t, testToken, m.auth.Auth.AccessToken.Native()) + + initiates, checks := srv.calls() + assert.Equal(t, 2, initiates) + assert.Equal(t, 3, checks) + srv.requireSecretsSent(testSecret, testSecret2, testSecret2) +} + +func TestModel_EnterOutsideExpiredIsIgnored(t *testing.T) { + srv := newFakeServer(t, []reply{defaultInitiate()}, []reply{pending()}) + rec := &recorder{} + _, tm := newTestModel(t, srv, rec) + + waitForOutput(t, tm, "Waiting for approval (") + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + srv.setChecks(approved()) + + _, err := finalResult(t, tm) + require.NoError(t, err) + initiates, _ := srv.calls() + assert.Equal(t, 1, initiates) +} diff --git a/src/deviceLogin/plain.go b/src/deviceLogin/plain.go new file mode 100644 index 00000000..18327b34 --- /dev/null +++ b/src/deviceLogin/plain.go @@ -0,0 +1,46 @@ +package deviceLogin + +import ( + "context" + "time" + + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/uxBlock/styles" + "github.com/zeropsio/zerops-go/dto/output" +) + +func runPlain(ctx context.Context, f flow, cfg Config) (output.AuthLogin, error) { + req, err := initiate(ctx, f, cfg) + if err != nil { + return output.AuthLogin{}, err + } + cfg.UxBlocks.PrintInfoText(i18n.T(i18n.DeviceLoginConfirmCode)) + cfg.UxBlocks.PrintInfo(styles.NewLine(styles.InfoText(styles.InfoIcon), " ", styles.InfoPrefix(), " ", styles.SelectText(req.userCode))) + cfg.UxBlocks.PrintInfoText(req.url) + cfg.UxBlocks.PrintInfoText(openBrowser(cfg, req.url)) + + timer := time.NewTimer(req.interval) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return output.AuthLogin{}, ctx.Err() + case <-timer.C: + } + res := check(ctx, f, req) + switch res.outcome { + case pollApproved: + return res.auth, nil + case pollDenied: + return output.AuthLogin{}, deniedError(f) + case pollExpired: + return output.AuthLogin{}, expiredError() + case pollFailed: + return output.AuthLogin{}, res.err + case pollRetry: + cfg.UxBlocks.LogDebug("device login: check failed, retrying: " + res.err.Error()) + case pollPending: + } + timer.Reset(req.interval) + } +} diff --git a/src/deviceLogin/request.go b/src/deviceLogin/request.go new file mode 100644 index 00000000..7e32a51a --- /dev/null +++ b/src/deviceLogin/request.go @@ -0,0 +1,107 @@ +package deviceLogin + +import ( + "context" + "net/http" + "time" + + "github.com/pkg/errors" + "github.com/zeropsio/zerops-go/apiError" + "github.com/zeropsio/zerops-go/dto/input/body" + "github.com/zeropsio/zerops-go/dto/output" + "github.com/zeropsio/zerops-go/errorCode" + "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/enum" + "github.com/zeropsio/zerops-go/types/uuid" +) + +type request struct { + id uuid.DeviceLoginRequestId + pollSecret types.String + userCode string + url string + expiresIn time.Duration + interval time.Duration + // checkTimeout bounds one check call, the client has no HTTP timeout and a black-holed request would block until the OS gives up. + checkTimeout time.Duration +} + +func initiate(ctx context.Context, f flow, cfg Config) (request, error) { + out, err := f.start(ctx, body.DeviceLoginInitiate{AppCode: types.NewString(cfg.AppCode)}) + if err != nil { + return request{}, err + } + interval := time.Duration(max(out.Interval.Native(), 1)) * cfg.unit + return request{ + id: out.RequestId, + pollSecret: out.PollSecret, + userCode: out.UserCode.Native(), + url: out.VerificationUrl.Native(), + expiresIn: time.Duration(out.ExpiresIn.Native()) * cfg.unit, + interval: interval, + checkTimeout: max(3*interval, 10*cfg.unit), + }, nil +} + +type pollOutcome int + +const ( + pollPending pollOutcome = iota + // pollRetry is a 5xx or any error without a decoded api error (transport, timeout, non-JSON body), polling continues. + pollRetry + pollApproved + pollDenied + pollExpired + // pollFailed ends polling with err. + pollFailed +) + +type pollResult struct { + outcome pollOutcome + auth output.AuthLogin + err error +} + +func check(ctx context.Context, f flow, req request) pollResult { + callCtx, cancel := context.WithTimeout(ctx, req.checkTimeout) + defer cancel() + resp, err := f.checker.PostAuthDeviceLoginCheck(callCtx, body.DeviceLoginCheck{RequestId: req.id, PollSecret: req.pollSecret}) + if err != nil { + if ctx.Err() != nil { + return pollResult{outcome: pollFailed, err: ctx.Err()} + } + return pollResult{outcome: pollRetry, err: err} + } + out, err := resp.Output() + if err != nil { + switch { + case apiError.HasErrorCode(err, errorCode.DeviceLoginNotFound): + return pollResult{outcome: pollExpired} + case isServerError(err): + return pollResult{outcome: pollRetry, err: err} + default: + return pollResult{outcome: pollFailed, err: err} + } + } + switch out.Status { + case enum.DeviceLoginStatusEnumPending: + return pollResult{outcome: pollPending} + case enum.DeviceLoginStatusEnumApproved: + if f.sudo { + return pollResult{outcome: pollApproved} + } + if out.Auth == nil { + return pollResult{outcome: pollFailed, err: errors.New("device login approved without auth")} + } + return pollResult{outcome: pollApproved, auth: *out.Auth} + case enum.DeviceLoginStatusEnumDenied: + return pollResult{outcome: pollDenied} + default: + return pollResult{outcome: pollFailed, err: errors.Errorf("unexpected device login status %q", out.Status)} + } +} + +func isServerError(err error) bool { + var apiErr apiError.Error + return errors.As(err, &apiErr) && apiErr.GetHttpStatusCode() >= http.StatusInternalServerError +} diff --git a/src/i18n/en.go b/src/i18n/en.go index a7b24222..983feb30 100644 --- a/src/i18n/en.go +++ b/src/i18n/en.go @@ -9,7 +9,10 @@ var en = map[string]string{ To unlock the full potential of zCLI, you need to log in using your Zerops account. Logging in enables you to access various features and interact with Zerops services seamlessly. -To log in, use the login command with your authentication token: +To log in using a browser, run: +zcli login + +To log in with an authentication token generated in your Zerops account, run: zcli login Alternatively, you can use the ZEROPS_TOKEN environment variable to authenticate: @@ -34,16 +37,51 @@ and your %s.`, // login CmdHelpLogin: "Help for the login command.", - CmdDescLogin: "Login into Zerops with generated Zerops token", + CmdDescLogin: "Log in to Zerops using a browser, or with a Zerops token", LoginSuccess: "You are logged as %s <%s>", + LoginBrowserFlag: "Log in using a browser even when the ZEROPS_TOKEN env variable is set.", + LoginEmptyToken: "The token is empty. Run `zcli login` without arguments to log in using a browser.", + LoginTokenAndBrowser: "Use either a token or the --browser flag, not both.", + LoginEnvToken: "Using the token from the %s env variable.", + LoginEnvOverrides: "The %s env variable takes precedence over the browser login until you unset it.", + LoginBrowserPrompt: "You are not logged in. Log in using a browser?", + LoginBrowserInCi: "Browser login is not available in CI. Use `zcli login ` or the %s env variable.", + LoginSessionExpired: "Your login session expired. Run `zcli login` to log in again.", RegionNotFound: "Selected region %s not found", RegionTableColumnName: "Name", + // device login + DeviceLoginConfirmCode: "Confirm that your browser shows this code:", + DeviceLoginBrowserOpened: "Opened in your browser. If it did not open, visit the link above.", + DeviceLoginOpenLink: "Open the link above in your browser.", + DeviceLoginWaiting: "Waiting for approval (%s)", + DeviceLoginExpired: "The code expired. Press Enter for a new one, Ctrl+C to quit.", + DeviceLoginExpiredError: "The code expired. Run the command again to get a new one.", + DeviceLoginPreviousExpired: "The previous code expired.", + DeviceLoginDenied: "The login was denied in the browser.", + DeviceLoginSudoDenied: "Sudo mode was denied in the browser.", + + // sudo + CmdHelpSudo: "Help for the sudo command.", + CmdDescSudo: "Show whether you are in sudo mode, and enable it using a browser", + SudoOn: "Sudo mode is on.", + SudoOnFor: "Sudo mode is on for %s more. Each command that needs it extends it.", + SudoOff: "Sudo mode is off.", + SudoEnablePrompt: "Enable sudo mode using a browser?", + SudoRequiredPrompt: "This command needs sudo mode. Enable it using a browser?", + SudoRequired: "This command needs sudo mode. Run `zcli sudo` to enable it.", + SudoInCi: "Sudo mode cannot be enabled in CI. Use a token with sudo mode enabled.", + SudoNotApplied: "Sudo mode was approved, but the session is still not in sudo mode. Run `zcli sudo` again.", + SudoTokenWithout: "This token does not have sudo mode. Enable it in the token settings in the Zerops GUI.", + // logout CmdHelpLogout: "Help for the logout command.", CmdDescLogout: "Disconnect from VPN and log out from your Zerops account", LogoutVpnDisconnecting: "Disconnecting from VPN. Please provide your password if prompted.", LogoutSuccess: "Successfully logged out. You are now disconnected from Zerops services.", + LogoutEnvToken: "You are still authenticated by the %s env variable until you unset it.", + LogoutRevokeFailed: "The login session could not be revoked on the server: %s", + LogoutNotLoggedIn: "You are not logged in.", // scope CmdHelpScope: "Help for the scope command.", @@ -327,6 +365,7 @@ at https://docs.zerops.io/references/cli for further details.`, PromptAllowedOnlyInTerminal: "Interactive prompt can be used only in terminal mode. Use --confirm=true flag to confirm it", UnauthenticatedUser: `unauthenticated user, login before proceeding with this command +zcli login zcli login {token} more info: https://docs.zerops.io/references/cli/`, diff --git a/src/i18n/i18n.go b/src/i18n/i18n.go index b87a70df..8953da60 100644 --- a/src/i18n/i18n.go +++ b/src/i18n/i18n.go @@ -30,14 +30,49 @@ const ( CmdHelpLogin = "CmdHelpLogin" CmdDescLogin = "CmdDescLogin" LoginSuccess = "LoginSuccess" + LoginBrowserFlag = "LoginBrowserFlag" + LoginEmptyToken = "LoginEmptyToken" + LoginTokenAndBrowser = "LoginTokenAndBrowser" + LoginEnvToken = "LoginEnvToken" + LoginEnvOverrides = "LoginEnvOverrides" + LoginBrowserPrompt = "LoginBrowserPrompt" + LoginBrowserInCi = "LoginBrowserInCi" + LoginSessionExpired = "LoginSessionExpired" RegionNotFound = "RegionNotFound" RegionTableColumnName = "RegionTableColumnName" + // device login + DeviceLoginConfirmCode = "DeviceLoginConfirmCode" + DeviceLoginBrowserOpened = "DeviceLoginBrowserOpened" + DeviceLoginOpenLink = "DeviceLoginOpenLink" + DeviceLoginWaiting = "DeviceLoginWaiting" + DeviceLoginExpired = "DeviceLoginExpired" + DeviceLoginExpiredError = "DeviceLoginExpiredError" + DeviceLoginPreviousExpired = "DeviceLoginPreviousExpired" + DeviceLoginDenied = "DeviceLoginDenied" + DeviceLoginSudoDenied = "DeviceLoginSudoDenied" + + // sudo + CmdHelpSudo = "CmdHelpSudo" + CmdDescSudo = "CmdDescSudo" + SudoOn = "SudoOn" + SudoOnFor = "SudoOnFor" + SudoOff = "SudoOff" + SudoEnablePrompt = "SudoEnablePrompt" + SudoRequiredPrompt = "SudoRequiredPrompt" + SudoRequired = "SudoRequired" + SudoInCi = "SudoInCi" + SudoTokenWithout = "SudoTokenWithout" + SudoNotApplied = "SudoNotApplied" + // logout CmdHelpLogout = "CmdHelpLogout" CmdDescLogout = "CmdDescLogout" LogoutVpnDisconnecting = "LogoutVpnDisconnecting" LogoutSuccess = "LogoutSuccess" + LogoutEnvToken = "LogoutEnvToken" + LogoutRevokeFailed = "LogoutRevokeFailed" + LogoutNotLoggedIn = "LogoutNotLoggedIn" // scope CmdHelpScope = "CmdHelpScope" diff --git a/src/loginSession/source.go b/src/loginSession/source.go new file mode 100644 index 00000000..fe31db8a --- /dev/null +++ b/src/loginSession/source.go @@ -0,0 +1,148 @@ +// Package loginSession keeps the access token of a browser login fresh. +package loginSession + +import ( + "context" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/cliStorage" + "github.com/zeropsio/zerops-go/apiError" + "github.com/zeropsio/zerops-go/dto/input/body" + "github.com/zeropsio/zerops-go/dto/output" + "github.com/zeropsio/zerops-go/errorCode" + "github.com/zeropsio/zerops-go/sdk" + "github.com/zeropsio/zerops-go/types" +) + +const ( + refreshMargin = 5 * time.Minute + refreshTimeout = 20 * time.Second +) + +var errNoRefreshToken = errors.New("auth response has no refresh token") + +type RefreshApi interface { + PostAuthRefresh(ctx context.Context, in body.AuthRefresh) (sdk.PostAuthRefreshResponse, error) +} + +// Source is a zeropsRestApiClient.TokenSource for the LoginSession in cliStorage. +// Zerops deletes the old token pair on refresh, so every refresh runs under the cross-process storage lock and first checks whether another process already refreshed. +type Source struct { + storage *cliStorage.Handler + api RefreshApi + now func() time.Time + + lock sync.Mutex + session cliStorage.LoginSession + expired bool +} + +func New(storage *cliStorage.Handler, api RefreshApi) *Source { + return &Source{ + storage: storage, + api: api, + now: time.Now, + session: storage.Data().LoginSession, + } +} + +// FromAuth converts the tokens of a login or a refresh into a LoginSession. +func FromAuth(auth output.AuthFull) (cliStorage.LoginSession, error) { + refreshToken, ok := auth.RefreshToken.Get() + if !ok || refreshToken.Native() == "" { + return cliStorage.LoginSession{}, errNoRefreshToken + } + return cliStorage.LoginSession{ + AccessToken: auth.AccessToken.Native(), + RefreshToken: refreshToken.Native(), + ExpiresAt: auth.ExpiresAt.Native(), + }, nil +} + +// Expired reports that Zerops rejected the refresh token and the stored session was cleared. +func (s *Source) Expired() bool { + s.lock.Lock() + defer s.lock.Unlock() + return s.expired +} + +// Token refreshes ahead of expiry. +func (s *Source) Token(ctx context.Context) (string, error) { + s.lock.Lock() + defer s.lock.Unlock() + s.refreshAhead(ctx, refreshMargin) + return s.session.AccessToken, nil +} + +// RefreshAhead refreshes the access token if it expires within margin. +func (s *Source) RefreshAhead(ctx context.Context, margin time.Duration) { + s.lock.Lock() + defer s.lock.Unlock() + s.refreshAhead(ctx, margin) +} + +func (s *Source) refreshAhead(ctx context.Context, margin time.Duration) { + if !s.expired && s.expiresWithin(s.session, margin) { + // If the refresh fails, the request goes out with the current token and a 401 leads to Renew. + _ = s.refresh(ctx, s.session.AccessToken, margin) + } +} + +func (s *Source) Renew(ctx context.Context, refused string) (string, bool, error) { + s.lock.Lock() + defer s.lock.Unlock() + if s.expired { + return "", false, nil + } + if err := s.refresh(ctx, refused, refreshMargin); err != nil { + return "", false, err + } + if s.expired { + return "", false, nil + } + return s.session.AccessToken, true, nil +} + +func (s *Source) expiresWithin(session cliStorage.LoginSession, margin time.Duration) bool { + return session.ExpiresAt.Sub(s.now()) < margin +} + +// refresh replaces the access token that the caller holds as current. +// A stored token that differs from current comes from another process, and it is used as is unless it expires within margin too. +func (s *Source) refresh(ctx context.Context, current string, margin time.Duration) error { + data, err := s.storage.Modify(func(data cliStorage.Data) (cliStorage.Data, error) { + stored := data.LoginSession + if !stored.IsSet() { + return data, nil + } + if stored.AccessToken != current && !s.expiresWithin(stored, margin) { + return data, nil + } + + // Without cancellation, because a canceled call may still rotate the tokens on the server and the new pair would be lost. + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), refreshTimeout) + defer cancel() + resp, err := s.api.PostAuthRefresh(ctx, body.AuthRefresh{RefreshTokenId: types.NewString(stored.RefreshToken)}) + if err != nil { + return data, errors.Wrap(err, "refresh login session") + } + auth, err := resp.Output() + if apiError.HasErrorCode(err, errorCode.RefreshTokenInvalid) || apiError.HasErrorCode(err, errorCode.UserNotFound) { + data.LoginSession = cliStorage.LoginSession{} + return data, nil + } + if err != nil { + return data, err + } + data.LoginSession, err = FromAuth(auth) + return data, err + }) + if err != nil { + return err + } + s.session = data.LoginSession + s.expired = !s.session.IsSet() + return nil +} diff --git a/src/loginSession/source_test.go b/src/loginSession/source_test.go new file mode 100644 index 00000000..83b47657 --- /dev/null +++ b/src/loginSession/source_test.go @@ -0,0 +1,224 @@ +package loginSession + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/zeropsio/zcli/src/cliStorage" + "github.com/zeropsio/zcli/src/storage" + "github.com/zeropsio/zcli/src/zeropsRestApiClient" +) + +// refreshServer rotates the token pair like Zerops does: a refresh token works once. +type refreshServer struct { + server *httptest.Server + calls atomic.Int32 + lock sync.Mutex + valid map[string]bool + next int + expires time.Duration + failAll bool +} + +func newRefreshServer(t *testing.T, validRefreshToken string) *refreshServer { + t.Helper() + s := &refreshServer{valid: map[string]bool{validRefreshToken: true}, expires: 6 * time.Hour} + s.server = httptest.NewServer(http.HandlerFunc(s.handle)) + t.Cleanup(s.server.Close) + return s +} + +func (s *refreshServer) handle(w http.ResponseWriter, r *http.Request) { + s.calls.Add(1) + w.Header().Set("Content-Type", "application/json") + if s.failAll { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":{"code":"internalServerError","message":"boom"}}`)) + return + } + var in struct { + RefreshTokenId string `json:"refreshTokenId"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + s.lock.Lock() + defer s.lock.Unlock() + if !s.valid[in.RefreshTokenId] { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"code":"refreshTokenInvalid","message":"invalid"}}`)) + return + } + delete(s.valid, in.RefreshTokenId) + s.next++ + refresh := fmt.Sprintf("refresh-%d", s.next) + s.valid[refresh] = true + _, _ = fmt.Fprintf( + w, + `{"accessToken":"access-%d","refreshToken":%q,"expiresAt":%q}`, + s.next, refresh, time.Now().Add(s.expires).UTC().Format(time.RFC3339), + ) +} + +func newStorage(t *testing.T, path string) *cliStorage.Handler { + t.Helper() + s, err := storage.New[cliStorage.Data](storage.Config{FilePath: path, FileMode: 0o600}) + require.NoError(t, err) + return &cliStorage.Handler{Handler: s} +} + +func seed(t *testing.T, path string, session cliStorage.LoginSession) { + t.Helper() + _, err := newStorage(t, path).Update(func(data cliStorage.Data) cliStorage.Data { + data.LoginSession = session + return data + }) + require.NoError(t, err) +} + +func newSource(t *testing.T, path string, server *refreshServer) *Source { + t.Helper() + return New(newStorage(t, path), zeropsRestApiClient.NewClient(server.server.URL)) +} + +func TestToken_FreshTokenIsNotRefreshed(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli.data") + server := newRefreshServer(t, "refresh-0") + seed(t, path, cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Hour)}) + + token, err := newSource(t, path, server).Token(t.Context()) + + require.NoError(t, err) + require.Equal(t, "access-0", token) + require.Zero(t, server.calls.Load()) +} + +func TestToken_RefreshesAheadOfExpiryAndStoresThePair(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli.data") + server := newRefreshServer(t, "refresh-0") + seed(t, path, cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Minute)}) + + token, err := newSource(t, path, server).Token(t.Context()) + + require.NoError(t, err) + require.Equal(t, "access-1", token) + stored := newStorage(t, path).Data().LoginSession + require.Equal(t, "access-1", stored.AccessToken) + require.Equal(t, "refresh-1", stored.RefreshToken) + require.True(t, stored.ExpiresAt.After(time.Now().Add(5*time.Hour))) +} + +func TestRefreshAhead_RefreshesWithinTheGivenMargin(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli.data") + server := newRefreshServer(t, "refresh-0") + seed(t, path, cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(10 * time.Minute)}) + source := newSource(t, path, server) + + token, err := source.Token(t.Context()) + require.NoError(t, err) + require.Equal(t, "access-0", token) + + source.RefreshAhead(t.Context(), 15*time.Minute) + token, err = source.Token(t.Context()) + require.NoError(t, err) + require.Equal(t, "access-1", token) + require.EqualValues(t, 1, server.calls.Load()) +} + +func TestRenew_AdoptsTokenThatAnotherProcessRefreshed(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli.data") + server := newRefreshServer(t, "refresh-0") + seed(t, path, cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Hour)}) + first := newSource(t, path, server) + second := newSource(t, path, server) + + renewed, ok, err := first.Renew(t.Context(), "access-0") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "access-1", renewed) + + renewed, ok, err = second.Renew(t.Context(), "access-0") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "access-1", renewed) + require.EqualValues(t, 1, server.calls.Load()) +} + +func TestRenew_ConcurrentProcessesRefreshOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli.data") + server := newRefreshServer(t, "refresh-0") + seed(t, path, cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Hour)}) + + var wg sync.WaitGroup + results := make([]string, 4) + for i := range results { + source := newSource(t, path, server) + wg.Go(func() { + renewed, ok, err := source.Renew(t.Context(), "access-0") + require.NoError(t, err) + require.True(t, ok) + results[i] = renewed + }) + } + wg.Wait() + + require.EqualValues(t, 1, server.calls.Load()) + for _, renewed := range results { + require.Equal(t, "access-1", renewed) + } +} + +func TestRenew_RejectedRefreshTokenClearsTheSession(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli.data") + server := newRefreshServer(t, "other") + seed(t, path, cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Hour)}) + source := newSource(t, path, server) + + _, ok, err := source.Renew(t.Context(), "access-0") + + require.NoError(t, err) + require.False(t, ok) + require.True(t, source.Expired()) + require.False(t, newStorage(t, path).Data().LoginSession.IsSet()) +} + +func TestRenew_ServerErrorKeepsTheSession(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli.data") + server := newRefreshServer(t, "refresh-0") + server.failAll = true + session := cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Hour).UTC().Truncate(time.Second)} + seed(t, path, session) + source := newSource(t, path, server) + + _, ok, err := source.Renew(t.Context(), "access-0") + + require.Error(t, err) + require.False(t, ok) + require.False(t, source.Expired()) + require.Equal(t, session.RefreshToken, newStorage(t, path).Data().LoginSession.RefreshToken) +} + +func TestRenew_LoggedOutElsewhereIsExpired(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli.data") + server := newRefreshServer(t, "refresh-0") + seed(t, path, cliStorage.LoginSession{AccessToken: "access-0", RefreshToken: "refresh-0", ExpiresAt: time.Now().Add(time.Hour)}) + source := newSource(t, path, server) + seed(t, path, cliStorage.LoginSession{}) + + _, ok, err := source.Renew(t.Context(), "access-0") + + require.NoError(t, err) + require.False(t, ok) + require.True(t, source.Expired()) + require.Zero(t, server.calls.Load()) +} diff --git a/src/storage/handler.go b/src/storage/handler.go index d09b9b8f..46e5de23 100644 --- a/src/storage/handler.go +++ b/src/storage/handler.go @@ -3,11 +3,12 @@ package storage import ( "encoding/json" "os" + "path/filepath" "sync" + "github.com/gofrs/flock" "github.com/pkg/errors" - "github.com/zeropsio/zcli/src/file" "github.com/zeropsio/zcli/src/i18n" ) @@ -16,6 +17,8 @@ type Config struct { FileMode os.FileMode } +// Handler keeps an in-memory copy of a JSON file that several processes share. +// Every write locks the file exclusively and applies the change to the data on disk, so a process never writes back stale data. type Handler[T any] struct { //nolint:structcheck // Why: `is unused` error is false positive config Config @@ -23,84 +26,163 @@ type Handler[T any] struct { data T //nolint:structcheck // Why: `is unused` error is false positive lock sync.RWMutex + // writeLock serializes writes within the process, because a Flock that is already locked returns immediately from Lock. + writeLock sync.Mutex + fileLock *flock.Flock } func New[T any](config Config) (*Handler[T], error) { h := &Handler[T]{ config: config, + // The lock file is readable by everyone, so a non-root zcli can still lock it after a sudo run created it. + fileLock: flock.New(config.FilePath+".lock", flock.SetPermissions(0644)), } - return h, h.load() + // Windows refuses a rename over a file that another process holds open, so a reader holds the shared lock. + err := h.withFileLock(h.fileLock.RLock, func() error { + data, err := h.read() + if err != nil { + return err + } + h.data = data + return nil + }) + return h, err +} + +// Update applies the callback to the data currently on disk and saves the result. +func (h *Handler[T]) Update(callback func(T) T) (T, error) { + return h.Modify(func(data T) (T, error) { + return callback(data), nil + }) +} + +// Modify applies the callback to the data currently on disk and saves the result. +// If the callback returns an error, nothing is written and Modify returns the current data with the error. +// Other processes wait for the file lock while the callback runs. +func (h *Handler[T]) Modify(callback func(T) (T, error)) (T, error) { + h.writeLock.Lock() + defer h.writeLock.Unlock() + + var result T + err := h.withFileLock(h.fileLock.Lock, func() error { + current, err := h.read() + if err != nil { + result = h.Data() + return err + } + h.setData(current) + result = current + + data, err := callback(current) + if err != nil { + return err + } + if err := h.save(data); err != nil { + return err + } + h.setData(data) + result = data + return nil + }) + return result, err +} + +func (h *Handler[T]) Clear() (T, error) { + h.writeLock.Lock() + defer h.writeLock.Unlock() + + var zero T + if err := h.withFileLock(h.fileLock.Lock, func() error { return h.save(zero) }); err != nil { + return h.Data(), err + } + h.setData(zero) + return zero, nil +} + +func (h *Handler[T]) Data() T { + h.lock.RLock() + defer h.lock.RUnlock() + return h.data +} + +func (h *Handler[T]) setData(data T) { + h.lock.Lock() + defer h.lock.Unlock() + h.data = data +} + +func (h *Handler[T]) withFileLock(lock func() error, fn func() error) (err error) { + if err := lock(); err != nil { + return errors.WithStack(err) + } + defer func() { + if unlockErr := h.fileLock.Unlock(); unlockErr != nil && err == nil { + err = errors.WithStack(unlockErr) + } + }() + return fn() } -func (h *Handler[T]) load() error { +// read expects the caller to hold the file lock. +func (h *Handler[T]) read() (T, error) { + var data T + fileInfo, err := os.Stat(h.config.FilePath) if os.IsNotExist(err) { - return nil + return data, nil } if err != nil { - return errors.WithStack(err) + return data, errors.WithStack(err) } if fileInfo.Size() == 0 { - if err := os.Remove(h.config.FilePath); err != nil { - return errors.WithStack(err) + // Another reader that holds the shared lock may remove the empty file first. + if err := os.Remove(h.config.FilePath); err != nil && !os.IsNotExist(err) { + return data, errors.WithStack(err) } - return nil + return data, nil } - f, err := file.Open(h.config.FilePath, os.O_RDONLY, h.config.FileMode) + f, err := os.Open(h.config.FilePath) if err != nil { - return errors.WithStack(err) + return data, errors.WithStack(err) } defer f.Close() - if err := json.NewDecoder(f).Decode(&h.data); err != nil { - return errors.WithMessage(err, i18n.T(i18n.UnableToDecodeJsonFile, h.config.FilePath)) + if err := json.NewDecoder(f).Decode(&data); err != nil { + return data, errors.WithMessage(err, i18n.T(i18n.UnableToDecodeJsonFile, h.config.FilePath)) } - return nil -} - -func (h *Handler[T]) Update(callback func(T) T) (T, error) { - h.lock.Lock() - defer h.lock.Unlock() - h.data = callback(h.data) - return h.data, h.save(h.data) + return data, nil } -func (h *Handler[T]) Clear() (T, error) { - h.lock.Lock() - defer h.lock.Unlock() - var t T - return h.data, h.save(t) -} - -func (h *Handler[T]) save(data T) error { - h.data = data - - if err := func() error { - f, err := file.Open(h.config.FilePath+".new", os.O_RDWR|os.O_CREATE|os.O_TRUNC, h.config.FileMode) +// save expects the caller to hold the exclusive file lock. +// The rename replaces the file atomically, so a reader never finds the file missing or half written. +func (h *Handler[T]) save(data T) (err error) { + f, err := os.CreateTemp(filepath.Dir(h.config.FilePath), filepath.Base(h.config.FilePath)+".*.tmp") + if err != nil { + return errors.WithStack(err) + } + defer func() { if err != nil { - return errors.WithStack(err) + _ = f.Close() + _ = os.Remove(f.Name()) } - defer f.Close() + }() - if err := json.NewEncoder(f).Encode(data); err != nil { - return errors.WithStack(err) - } - return nil - }(); err != nil { - return err + if err := os.Chmod(f.Name(), h.config.FileMode); err != nil { + return errors.WithStack(err) + } + if err := json.NewEncoder(f).Encode(data); err != nil { + return errors.WithStack(err) + } + if err := f.Sync(); err != nil { + return errors.WithStack(err) + } + if err := f.Close(); err != nil { + return errors.WithStack(err) } - os.Remove(h.config.FilePath) - defer os.Remove(h.config.FilePath + ".new") - if err := os.Rename(h.config.FilePath+".new", h.config.FilePath); err != nil { + if err := rename(f.Name(), h.config.FilePath); err != nil { return errors.WithStack(err) } return nil } - -func (h *Handler[T]) Data() T { - h.lock.RLock() - defer h.lock.RUnlock() - return h.data -} diff --git a/src/storage/handler_test.go b/src/storage/handler_test.go index 8dc8b5b2..7640b1ec 100644 --- a/src/storage/handler_test.go +++ b/src/storage/handler_test.go @@ -1,20 +1,53 @@ package storage import ( + "errors" "os" + "os/exec" + "path/filepath" + "sync" + "sync/atomic" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +const ( + helperProcessFileEnv = "ZCLI_STORAGE_HELPER_PROCESS_FILE" + helperProcessUpdates = 50 +) + +type counterData struct { + Counter int + X string + Y string +} + +func newCounterHandler(t *testing.T, filePath string) *Handler[counterData] { + t.Helper() + h, err := New[counterData](Config{ + FilePath: filePath, + FileMode: 0600, + }) + require.NoError(t, err) + return h +} + +func increment(data counterData) counterData { + data.Counter++ + return data +} + func TestStorage(t *testing.T) { type dataObject struct { Param string } + filePath := filepath.Join(t.TempDir(), "test") { storage, err := New[dataObject](Config{ - FilePath: "./test", + FilePath: filePath, FileMode: 0666, }) require.NoError(t, err) @@ -36,14 +69,196 @@ func TestStorage(t *testing.T) { { storage, err := New[dataObject](Config{ - FilePath: "./test", + FilePath: filePath, }) require.NoError(t, err) d := storage.Data() require.Equal(t, "value", d.Param) } +} + +func TestStorage_EmptyFile(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "data") + require.NoError(t, os.WriteFile(filePath, nil, 0600)) + + h := newCounterHandler(t, filePath) + require.Equal(t, counterData{}, h.Data()) + require.NoFileExists(t, filePath) +} + +func TestStorage_Clear(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "data") + h := newCounterHandler(t, filePath) + _, err := h.Update(increment) + require.NoError(t, err) + + d, err := h.Clear() + require.NoError(t, err) + require.Equal(t, counterData{}, d) + require.Equal(t, counterData{}, h.Data()) + require.Equal(t, counterData{}, newCounterHandler(t, filePath).Data()) +} + +func TestStorage_ConcurrentHandlers(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "data") + const handlers = 4 + const updates = 50 + + var wg sync.WaitGroup + for range handlers { + h := newCounterHandler(t, filePath) + wg.Go(func() { + for range updates { + _, err := h.Update(increment) + assert.NoError(t, err) + } + }) + } + wg.Wait() + + require.Equal(t, handlers*updates, newCounterHandler(t, filePath).Data().Counter) + tmpFiles, err := filepath.Glob(filepath.Join(dir, "*.tmp")) + require.NoError(t, err) + require.Empty(t, tmpFiles) + require.FileExists(t, filePath+".lock") +} + +func TestStorage_ConcurrentUpdatesOnOneHandler(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "data") + h := newCounterHandler(t, filePath) + const goroutines = 4 + const updates = 50 + + var wg sync.WaitGroup + for range goroutines { + wg.Go(func() { + for range updates { + _, err := h.Update(increment) + assert.NoError(t, err) + } + }) + } + wg.Wait() + + require.Equal(t, goroutines*updates, h.Data().Counter) + require.Equal(t, goroutines*updates, newCounterHandler(t, filePath).Data().Counter) +} + +func TestStorage_StaleHandlerKeepsOtherWrites(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "data") + a := newCounterHandler(t, filePath) + b := newCounterHandler(t, filePath) - err := os.Remove("./test") + _, err := b.Update(func(data counterData) counterData { + data.X = "x" + return data + }) require.NoError(t, err) + + d, err := a.Update(func(data counterData) counterData { + data.Y = "y" + return data + }) + require.NoError(t, err) + require.Equal(t, counterData{X: "x", Y: "y"}, d) + require.Equal(t, d, a.Data()) + require.Equal(t, d, newCounterHandler(t, filePath).Data()) +} + +func TestStorage_ModifyErrorWritesNothing(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "data") + a := newCounterHandler(t, filePath) + b := newCounterHandler(t, filePath) + + _, err := b.Update(func(data counterData) counterData { + data.X = "x" + return data + }) + require.NoError(t, err) + before, err := os.ReadFile(filePath) + require.NoError(t, err) + + errCallback := errors.New("callback failed") + d, err := a.Modify(func(data counterData) (counterData, error) { + data.Y = "y" + return data, errCallback + }) + require.ErrorIs(t, err, errCallback) + require.Equal(t, counterData{X: "x"}, d) + require.Equal(t, counterData{X: "x"}, a.Data()) + + after, err := os.ReadFile(filePath) + require.NoError(t, err) + require.Equal(t, before, after) +} + +func TestStorage_ReaderNeverSeesMissingFile(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "data") + _, err := newCounterHandler(t, filePath).Update(increment) + require.NoError(t, err) + + var writersDone atomic.Bool + var writers sync.WaitGroup + for range 2 { + h := newCounterHandler(t, filePath) + writers.Go(func() { + for range 100 { + _, err := h.Update(increment) + assert.NoError(t, err) + } + }) + } + + var reader sync.WaitGroup + reader.Go(func() { + for !writersDone.Load() { + h, err := New[counterData](Config{ + FilePath: filePath, + FileMode: 0600, + }) + //nolint:testifylint // Why: require must not stop the test from outside the test goroutine. + if !assert.NoError(t, err) { + return + } + if !assert.NotZero(t, h.Data().Counter) { + return + } + } + }) + + writers.Wait() + writersDone.Store(true) + reader.Wait() +} + +func TestStorage_ConcurrentProcesses(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "data") + const processes = 4 + + var wg sync.WaitGroup + for range processes { + wg.Go(func() { + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestStorage_HelperProcess$") + cmd.Env = append(os.Environ(), helperProcessFileEnv+"="+filePath) + out, err := cmd.CombinedOutput() + assert.NoError(t, err, string(out)) + }) + } + wg.Wait() + + require.Equal(t, processes*helperProcessUpdates, newCounterHandler(t, filePath).Data().Counter) +} + +func TestStorage_HelperProcess(t *testing.T) { + filePath := os.Getenv(helperProcessFileEnv) + if filePath == "" { + t.Skip("runs only as a child of TestStorage_ConcurrentProcesses") + } + h := newCounterHandler(t, filePath) + for range helperProcessUpdates { + _, err := h.Update(increment) + require.NoError(t, err) + } } diff --git a/src/storage/rename_other.go b/src/storage/rename_other.go new file mode 100644 index 00000000..d090a155 --- /dev/null +++ b/src/storage/rename_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package storage + +import "os" + +func rename(oldPath, newPath string) error { + return os.Rename(oldPath, newPath) +} diff --git a/src/storage/rename_windows.go b/src/storage/rename_windows.go new file mode 100644 index 00000000..47f47cba --- /dev/null +++ b/src/storage/rename_windows.go @@ -0,0 +1,32 @@ +package storage + +import ( + "errors" + "os" + "syscall" + "time" +) + +// errSharingViolation is ERROR_SHARING_VIOLATION, which the syscall package does not define. +const errSharingViolation syscall.Errno = 32 + +// Windows refuses to replace a file that another process holds open. +// constants.checkPath opens the data file without the lock at every startup, so rename retries for about 200ms. +func rename(oldPath, newPath string) error { + const attempts = 10 + var err error + for attempt := range attempts { + err = os.Rename(oldPath, newPath) + if !isHeldOpen(err) { + return err + } + if attempt < attempts-1 { + time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond) + } + } + return err +} + +func isHeldOpen(err error) bool { + return errors.Is(err, syscall.ERROR_ACCESS_DENIED) || errors.Is(err, errSharingViolation) +} diff --git a/src/terminal/terminal.go b/src/terminal/terminal.go index 7c8f8f6c..ecbcda78 100644 --- a/src/terminal/terminal.go +++ b/src/terminal/terminal.go @@ -67,3 +67,8 @@ func GetSize() (width int, height int) { } return width, height } + +// IsInteractive also requires a terminal on stdin, because prompts read keys from it. +func IsInteractive() bool { + return IsTerminal() && (isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd())) +} diff --git a/src/zeropsRestApiClient/auth.go b/src/zeropsRestApiClient/auth.go new file mode 100644 index 00000000..8d666411 --- /dev/null +++ b/src/zeropsRestApiClient/auth.go @@ -0,0 +1,72 @@ +package zeropsRestApiClient + +import ( + "context" + "net/http" +) + +type TokenSource interface { + // Token returns the token for the next request. + Token(ctx context.Context) (string, error) + // Renew is called after the API refused the token with 401. + // It returns false when there is no other token to retry with, and the client then returns the 401 response. + Renew(ctx context.Context, refused string) (string, bool, error) +} + +type StaticToken string + +func (t StaticToken) Token(context.Context) (string, error) { + return string(t), nil +} + +func (t StaticToken) Renew(context.Context, string) (string, bool, error) { + return "", false, nil +} + +type authTransport struct { + base http.RoundTripper + source TokenSource +} + +func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + token, err := t.source.Token(ctx) + if err != nil { + return nil, err + } + resp, err := t.base.RoundTrip(withToken(req, token)) + if err != nil || resp.StatusCode != http.StatusUnauthorized { + return resp, err + } + // A request with a body that cannot be read again cannot be retried. + if req.Body != nil && req.Body != http.NoBody && req.GetBody == nil { + return resp, nil + } + + renewed, ok, err := t.source.Renew(ctx, token) + if err != nil { + resp.Body.Close() + return nil, err + } + if !ok { + return resp, nil + } + retry := withToken(req, renewed) + if req.GetBody != nil { + body, err := req.GetBody() + if err != nil { + resp.Body.Close() + return nil, err + } + retry.Body = body + } + resp.Body.Close() + return t.base.RoundTrip(retry) +} + +// withToken returns a copy of req, because a RoundTripper must not modify the request it was given. +func withToken(req *http.Request, token string) *http.Request { + clone := req.Clone(req.Context()) + clone.Header.Set("Authorization", "Bearer "+token) + return clone +} diff --git a/src/zeropsRestApiClient/auth_test.go b/src/zeropsRestApiClient/auth_test.go new file mode 100644 index 00000000..6dcb7902 --- /dev/null +++ b/src/zeropsRestApiClient/auth_test.go @@ -0,0 +1,148 @@ +package zeropsRestApiClient + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +type fakeSource struct { + lock sync.Mutex + token string + renewed string + renewOk bool + err error + refused []string +} + +func (s *fakeSource) Token(context.Context) (string, error) { + s.lock.Lock() + defer s.lock.Unlock() + return s.token, nil +} + +func (s *fakeSource) Renew(_ context.Context, refused string) (string, bool, error) { + s.lock.Lock() + defer s.lock.Unlock() + s.refused = append(s.refused, refused) + if s.err != nil || !s.renewOk { + return "", false, s.err + } + s.token = s.renewed + return s.renewed, true, nil +} + +// acceptServer answers 401 to every token except valid and records the token and body of each request. +func acceptServer(t *testing.T, valid string) (*httptest.Server, *[]string, *[]string) { + t.Helper() + var lock sync.Mutex + var tokens, bodies []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + lock.Lock() + tokens = append(tokens, r.Header.Get("Authorization")) + bodies = append(bodies, string(body)) + lock.Unlock() + if r.Header.Get("Authorization") != "Bearer "+valid { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + })) + t.Cleanup(server.Close) + return server, &tokens, &bodies +} + +// post returns the response status code. +func post(t *testing.T, client *http.Client, url string, body string) int { + t.Helper() + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, strings.NewReader(body)) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + return resp.StatusCode +} + +func TestAuthTransport_RetriesWithRenewedTokenAndBody(t *testing.T) { + server, tokens, bodies := acceptServer(t, "fresh") + source := &fakeSource{token: "stale", renewed: "fresh", renewOk: true} + client := &http.Client{Transport: &authTransport{base: newTransport(), source: source}} + + status := post(t, client, server.URL, `{"a":1}`) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, []string{"Bearer stale", "Bearer fresh"}, *tokens) + require.Equal(t, []string{`{"a":1}`, `{"a":1}`}, *bodies) + require.Equal(t, []string{"stale"}, source.refused) +} + +func TestAuthTransport_ReturnsUnauthorizedWithoutRenewal(t *testing.T) { + server, tokens, _ := acceptServer(t, "fresh") + client := &http.Client{Transport: &authTransport{base: newTransport(), source: StaticToken("personal")}} + + status := post(t, client, server.URL, "") + + require.Equal(t, http.StatusUnauthorized, status) + require.Equal(t, []string{"Bearer personal"}, *tokens) +} + +func TestAuthTransport_RetriesOnlyOnce(t *testing.T) { + server, tokens, _ := acceptServer(t, "never") + source := &fakeSource{token: "stale", renewed: "also-refused", renewOk: true} + client := &http.Client{Transport: &authTransport{base: newTransport(), source: source}} + + status := post(t, client, server.URL, "") + + require.Equal(t, http.StatusUnauthorized, status) + require.Len(t, *tokens, 2) +} + +func TestAuthTransport_RenewErrorIsReturned(t *testing.T) { + server, _, _ := acceptServer(t, "fresh") + source := &fakeSource{token: "stale", err: errors.New("network down")} + client := &http.Client{Transport: &authTransport{base: newTransport(), source: source}} + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL, nil) + require.NoError(t, err) + resp, err := client.Do(req) + if resp != nil { + resp.Body.Close() + } + + require.ErrorContains(t, err, "network down") +} + +func TestAuthTransport_DoesNotRetryUnreadableBody(t *testing.T) { + server, tokens, _ := acceptServer(t, "fresh") + source := &fakeSource{token: "stale", renewed: "fresh", renewOk: true} + client := &http.Client{Transport: &authTransport{base: newTransport(), source: source}} + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL, io.NopCloser(strings.NewReader("x"))) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.Len(t, *tokens, 1) + require.Empty(t, source.refused) +} + +func TestNewTokenSourceClient_SendsBearerToken(t *testing.T) { + server, tokens, _ := acceptServer(t, "personal") + + _, err := NewAuthorizedClient("personal", server.URL).GetUserInfo(t.Context()) + + require.NoError(t, err) + require.Equal(t, []string{"Bearer personal"}, *tokens) +} diff --git a/src/zeropsRestApiClient/handler.go b/src/zeropsRestApiClient/handler.go index 9ac7a0e7..894f4a76 100644 --- a/src/zeropsRestApiClient/handler.go +++ b/src/zeropsRestApiClient/handler.go @@ -11,27 +11,36 @@ import ( type Handler struct { sdk.Handler - env sdkBase.Environment } -// NewAuthorizedClient builds an authorized SDK client. baseURL may be either a -// bare host ("api.example.com") or a full URL ("https://api.example.com", -// "http://127.0.0.1:1234"); a missing scheme defaults to https. +// NewClient builds an SDK client without authorization, for endpoints like the device login or the token refresh. +// baseURL may be either a bare host ("api.example.com") or a full URL ("https://api.example.com", "http://127.0.0.1:1234"), a missing scheme defaults to https. +func NewClient(baseURL string) *Handler { + return newHandler(baseURL, newTransport()) +} + +// NewAuthorizedClient builds an SDK client that sends the given static token. func NewAuthorizedClient(token string, baseURL string) *Handler { + return NewTokenSourceClient(StaticToken(token), baseURL) +} + +// NewTokenSourceClient builds an SDK client that takes its token from source and retries a refused request once with a renewed token. +func NewTokenSourceClient(source TokenSource, baseURL string) *Handler { + return newHandler(baseURL, &authTransport{base: newTransport(), source: source}) +} + +func newHandler(baseURL string, transport http.RoundTripper) *Handler { if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { baseURL = "https://" + baseURL } config := sdkBase.DefaultConfig(sdkBase.WithCustomEndpoint(baseURL)) - - httpClient := &http.Client{ - Transport: &http.Transport{ - DisableKeepAlives: true, - }, + return &Handler{ + Handler: sdk.New(config, &http.Client{Transport: transport}), } +} - return &Handler{ - Handler: sdk.AuthorizeSdk(sdk.New(config, httpClient), token), - // temporary solution, I need my own endpoints - env: sdkBase.NewEnvironment(config, httpClient).Authorize(token), +func newTransport() http.RoundTripper { + return &http.Transport{ + DisableKeepAlives: true, } }