Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"fmt"
"os"
"os/signal"
"strings"
"time"

"github.com/pterm/pterm"
"github.com/urfave/cli/v2"

"github.com/NodeOps-app/createos-cli/internal/api"
"github.com/NodeOps-app/createos-cli/internal/config"
internaloauth "github.com/NodeOps-app/createos-cli/internal/oauth"
"github.com/NodeOps-app/createos-cli/internal/terminal"
Expand Down Expand Up @@ -42,11 +44,7 @@ func NewLoginCommand() *cli.Command {
}
// --token flag: API key flow (works in both TTY and non-TTY)
if token := c.String("token"); token != "" {
if err := config.SaveToken(token); err != nil {
return fmt.Errorf("could not save your token: %w", err)
}
pterm.Success.Println("You're signed in.")
return nil
return loginWithAPIToken(c, token)
}

if c.Bool("device") {
Expand Down Expand Up @@ -75,18 +73,30 @@ func NewLoginCommand() *cli.Command {
return loginWithDevice(c)
}
if selected == options[2] {
return loginWithAPIToken()
return promptForAPIToken(c)
}
return loginWithBrowser()
},
}
}

func loginWithAPIToken() error {
func promptForAPIToken(c *cli.Context) error {
token, err := pterm.DefaultInteractiveTextInput.WithMask("*").Show("Paste your API token")
if err != nil || token == "" {
return fmt.Errorf("sign in cancelled")
}
return loginWithAPIToken(c, token)
}

func loginWithAPIToken(c *cli.Context, token string) error {
token = strings.TrimSpace(token)
if token == "" {
return fmt.Errorf("please provide an API token from your CreateOS dashboard")
}
client := api.NewClient(token, c.String("api-url"), c.Bool("debug"))
if _, err := client.GetUser(); err != nil {
return fmt.Errorf("could not verify your API token: %w", err)
}
if err := config.SaveToken(token); err != nil {
return fmt.Errorf("could not save your token: %w", err)
}
Expand Down
75 changes: 75 additions & 0 deletions cmd/auth/login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package auth

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/urfave/cli/v2"

"github.com/NodeOps-app/createos-cli/internal/config"
)

func TestLoginWithAPITokenValidatesBeforeSaving(t *testing.T) {
for _, tc := range []struct {
name string
token string
statusCode int
wantToken string
wantError string
}{
{"valid", " valid-token ", http.StatusOK, "valid-token", ""},
{"invalid", "abcd", http.StatusUnauthorized, "existing-token", "could not verify your API token"},
{"server error", "valid-token", http.StatusServiceUnavailable, "existing-token", "could not verify your API token"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
if err := config.SaveToken("existing-token"); err != nil {
t.Fatal(err)
}
var requests int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.Method != http.MethodGet || r.URL.Path != "/v1/users/me" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.Header.Get("X-Api-Key"); got != strings.TrimSpace(tc.token) {
t.Errorf("API key header = %q, want %q", got, strings.TrimSpace(tc.token))
}
if got := r.UserAgent(); got != "createos-cli" {
t.Errorf("User-Agent = %q, want createos-cli", got)
}
w.WriteHeader(tc.statusCode)
if tc.statusCode == http.StatusOK {
_, _ = w.Write([]byte(`{"status":"success","data":{"id":"user-1"}}`))
} else {
_, _ = w.Write([]byte(`{"status":"fail","data":"invalid api key"}`))
}
}))
defer server.Close()

app := &cli.App{
Flags: []cli.Flag{&cli.StringFlag{Name: "api-url"}},
Commands: []*cli.Command{NewLoginCommand()},
}
err := app.Run([]string{"createos", "--api-url", server.URL, "login", "--token", tc.token})
if tc.wantError == "" && err != nil {
t.Fatalf("login failed: %v", err)
}
if tc.wantError != "" && (err == nil || !strings.Contains(err.Error(), tc.wantError)) {
t.Fatalf("login error = %v, want %q", err, tc.wantError)
}
if requests != 1 {
t.Errorf("requests = %d, want 1", requests)
}
gotToken, err := config.LoadToken()
if err != nil {
t.Fatal(err)
}
if gotToken != tc.wantToken {
t.Errorf("saved token = %q, want %q", gotToken, tc.wantToken)
}
})
}
}
3 changes: 3 additions & 0 deletions cmd/sandbox/mutagen_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
"path/filepath"
"runtime"
"time"

"github.com/NodeOps-app/createos-cli/internal/httpclient"
)

// Mutagen version pinned by createos. Bump this when we want a newer
Expand Down Expand Up @@ -163,6 +165,7 @@ func downloadAndExtractMutagen(url, ext, target string) error {
if err != nil {
return err
}
httpclient.SetUserAgent(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("fetch %s: %w", url, err)
Expand Down
5 changes: 4 additions & 1 deletion cmd/sandbox/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"golang.org/x/term"

"github.com/NodeOps-app/createos-cli/internal/api"
"github.com/NodeOps-app/createos-cli/internal/httpclient"
"github.com/NodeOps-app/createos-cli/internal/terminal"
)

Expand Down Expand Up @@ -423,6 +424,7 @@ func dialControlUpgrade(ctx context.Context, ctrlURL, authHeader, token, path st
req := "POST " + path + " HTTP/1.1\r\n" +
"Host: " + u.Host + "\r\n" +
authHeader + ": " + token + "\r\n" +
"User-Agent: " + httpclient.UserAgent + "\r\n" +
"Connection: Upgrade\r\n" +
"Upgrade: tcp-tunnel\r\n" +
"Content-Length: 0\r\n\r\n"
Expand Down Expand Up @@ -616,8 +618,9 @@ func dialControlTunnel(ctx context.Context, ctrlURL, authHeader, token, id strin

req := fmt.Sprintf("POST /v1/sandboxes/%s/tunnel/%d HTTP/1.1\r\n"+
"Host: %s\r\n%s: %s\r\n"+
"User-Agent: %s\r\n"+
"Connection: Upgrade\r\nUpgrade: tcp-tunnel\r\nContent-Length: 0\r\n\r\n",
id, port, u.Host, authHeader, token)
id, port, u.Host, authHeader, token, httpclient.UserAgent)
if _, err = conn.Write([]byte(req)); err != nil {
_ = conn.Close() //nolint:errcheck
return nil, err
Expand Down
2 changes: 2 additions & 0 deletions cmd/templates/use.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/urfave/cli/v2"

"github.com/NodeOps-app/createos-cli/internal/api"
"github.com/NodeOps-app/createos-cli/internal/httpclient"
"github.com/NodeOps-app/createos-cli/internal/terminal"
)

Expand Down Expand Up @@ -112,6 +113,7 @@ func newTemplatesUseCommand() *cli.Command {
if err != nil {
return fmt.Errorf("could not create download request: %w", err)
}
httpclient.SetUserAgent(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("could not download template: %w", err)
Expand Down
5 changes: 5 additions & 0 deletions cmd/upgrade/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/urfave/cli/v2"
"golang.org/x/mod/semver"

"github.com/NodeOps-app/createos-cli/internal/httpclient"
"github.com/NodeOps-app/createos-cli/internal/pkg/version"
)

Expand Down Expand Up @@ -163,6 +164,7 @@ func fetchLatestRelease() (*githubRelease, error) {
if err != nil {
return nil, err
}
httpclient.SetUserAgent(req)
req.Header.Set("Accept", "application/vnd.github+json")

resp, err := httpClient.Do(req)
Expand Down Expand Up @@ -233,6 +235,7 @@ func downloadToTemp(rawURL, assetName string) (string, error) {
if err != nil {
return "", err
}
httpclient.SetUserAgent(req)

client := &http.Client{Timeout: downloadTimeout}
resp, err := client.Do(req)
Expand Down Expand Up @@ -304,6 +307,7 @@ func fetchNightlyCommit(release *githubRelease) (string, error) {
if err != nil {
return "", err
}
httpclient.SetUserAgent(req)

resp, err := httpClient.Do(req)
if err != nil {
Expand Down Expand Up @@ -338,6 +342,7 @@ func fetchChecksum(rawURL string) (string, error) {
if err != nil {
return "", err
}
httpclient.SetUserAgent(req)

resp, err := httpClient.Do(req)
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"strings"

"github.com/go-resty/resty/v2"

"github.com/NodeOps-app/createos-cli/internal/httpclient"
)

// TokenRefresher obtains a fresh access token after the server rejects
Expand Down Expand Up @@ -80,6 +82,7 @@ func NewClient(token, apiURL string, debug bool) APIClient {

client := resty.New().
SetBaseURL(apiURL).
SetHeader("User-Agent", httpclient.UserAgent).
SetHeader(headerAPIKey, token).
SetHeader("Content-Type", "application/json")

Expand All @@ -104,6 +107,7 @@ func NewClientWithAccessToken(accessToken, apiURL string, debug bool, refresher

client := resty.New().
SetBaseURL(apiURL).
SetHeader("User-Agent", httpclient.UserAgent).
SetHeader(headerAccessToken, accessToken).
SetHeader("Content-Type", "application/json")

Expand Down
35 changes: 35 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package api

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/go-resty/resty/v2"

"github.com/NodeOps-app/createos-cli/internal/httpclient"
)

func TestClientsSendUserAgent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.UserAgent(); got != httpclient.UserAgent {
t.Errorf("User-Agent = %q, want %q", got, httpclient.UserAgent)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

clients := map[string]*resty.Client{
"API key": NewClient("key", server.URL, false).Client,
"OAuth": NewClientWithAccessToken("token", server.URL, false, nil).Client,
"sandbox API key": NewSandboxClient("key", server.URL, false).Client,
"sandbox OAuth": NewSandboxClientWithAccessToken("token", server.URL, false, nil).Client,
}
for name, client := range clients {
t.Run(name, func(t *testing.T) {
if _, err := client.R().Get("/"); err != nil {
t.Fatal(err)
}
})
}
}
3 changes: 3 additions & 0 deletions internal/api/sandbox_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package api

import (
"github.com/go-resty/resty/v2"

"github.com/NodeOps-app/createos-cli/internal/httpclient"
)

// DefaultSandboxBaseURL is the default fc-spawn API base URL. The
Expand Down Expand Up @@ -58,6 +60,7 @@ func newSandboxClient(authHeader, token, sandboxURL string, debug bool, refreshe
}
client := resty.New().
SetBaseURL(sandboxURL).
SetHeader("User-Agent", httpclient.UserAgent).
SetHeader(authHeader, token).
SetHeader("Content-Type", "application/json")
if debug {
Expand Down
11 changes: 11 additions & 0 deletions internal/httpclient/agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package httpclient

import "net/http"

// UserAgent is sent with HTTP requests made by the CLI.
const UserAgent = "createos-cli"

// SetUserAgent identifies HTTP requests made by the CLI.
func SetUserAgent(req *http.Request) {
req.Header.Set("User-Agent", UserAgent)
}
3 changes: 3 additions & 0 deletions internal/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"os"
"path/filepath"
"strings"

"github.com/NodeOps-app/createos-cli/internal/httpclient"
)

// InstallScope represents the scope of a skill install (local or global).
Expand Down Expand Up @@ -74,6 +76,7 @@ func InstallToScope(downloadURL, uniqueName string, scope InstallScope) ([]strin
if err != nil {
return nil, fmt.Errorf("download request failed: %w", err)
}
httpclient.SetUserAgent(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
Expand Down
3 changes: 3 additions & 0 deletions internal/oauth/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"net/url"
"strings"
"time"

"github.com/NodeOps-app/createos-cli/internal/httpclient"
)

const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
Expand Down Expand Up @@ -118,6 +120,7 @@ func devicePost(ctx context.Context, endpoint string, form url.Values) (int, []b
if err != nil {
return 0, nil, fmt.Errorf("could not start sign in — run 'createos login' to try again")
}
httpclient.SetUserAgent(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Do(req)
Expand Down
5 changes: 5 additions & 0 deletions internal/oauth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"runtime"
"strings"
"time"

"github.com/NodeOps-app/createos-cli/internal/httpclient"
)

// ServerMetadata holds the OAuth authorization server metadata (RFC 8414)
Expand Down Expand Up @@ -50,6 +52,7 @@ func FetchServerMetadataContext(ctx context.Context, baseURL string) (*ServerMet
if err != nil {
return nil, fmt.Errorf("could not reach authorization server")
}
httpclient.SetUserAgent(req)
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return nil, fmt.Errorf("could not reach authorization server — check your internet connection")
Expand Down Expand Up @@ -112,6 +115,7 @@ func ExchangeCode(tokenEndpoint, clientID, code, redirectURI, verifier string) (
if err != nil {
return nil, fmt.Errorf("login failed — could not initiate token exchange")
}
httpclient.SetUserAgent(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
Expand Down Expand Up @@ -139,6 +143,7 @@ func RefreshTokens(tokenEndpoint, clientID, refreshToken string) (*TokenResponse
if err != nil {
return nil, fmt.Errorf("session refresh failed — could not initiate request")
}
httpclient.SetUserAgent(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
Expand Down
Loading
Loading