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
1 change: 1 addition & 0 deletions .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ jobs:
make build \
BINARY="${OUT_DIR}/${BINARY_NAME}" \
DEFAULT_API_URL="${CLI_DEFAULT_API_URL}" \
DEFAULT_WEB_URL="${CLI_DEFAULT_WEB_URL}" \
FIRST_PARTY_DEVICE_CLIENT_ID="${CLI_FIRST_PARTY_DEVICE_CLIENT_ID}" \
VERSION="${CLI_VERSION}" \
DATE="${BUILD_DATE}"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/volcano
/dist/
/.env
/.env.local
/.env.*
*.test
*.out
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,22 @@ no separate install step is required.
| Goal | Command |
| ----------------------------- | ---------------------------------------- |
| Build the binary | `make build` |
| Build against a dev backend | `make local` |
| Run tests | `make test` |
| Lint (golangci-lint) | `make lint` |
| Lint + test | `make check` |
| Tidy module dependencies | `make tidy` |
| Local-mode smoke test | `make localmode-e2e` |

`make local` builds the binary with the compiled-in defaults loaded from a
gitignored `.env.local` file, so you can point the CLI at a non-production
backend without exporting variables each time. Supported keys: `VOLCANO_API_URL`,
`VOLCANO_WEB_URL` (signup/login pages), and `VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID`.
When only a loopback `VOLCANO_API_URL` is set, `VOLCANO_WEB_URL` defaults to
`http://localhost:3000`. At runtime, `volcano signup` follows the backend's
device-flow verification URL (like `volcano login`), so `VOLCANO_WEB_URL` only
overrides that web origin.

`make localmode-e2e` uses Docker and is intentionally heavier than the normal
unit-test workflow. Run it when changing local-mode startup, reset, health, or
Docker integration behavior.
Expand Down
25 changes: 24 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,45 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
DEFAULT_API_URL ?= https://api.volcano.dev
DEFAULT_WEB_URL ?= https://volcano.dev
FIRST_PARTY_DEVICE_CLIENT_ID ?=

LDFLAGS := -s -w \
-X $(VERSION_PKG).Version=$(VERSION) \
-X $(VERSION_PKG).Commit=$(COMMIT) \
-X $(VERSION_PKG).Date=$(DATE) \
-X $(CONFIG_PKG).compiledDefaultAPIURL=$(DEFAULT_API_URL) \
-X $(CONFIG_PKG).compiledDefaultWebURL=$(DEFAULT_WEB_URL) \
-X $(CONFIG_PKG).compiledFirstPartyDeviceClientID=$(FIRST_PARTY_DEVICE_CLIENT_ID)

.PHONY: all build test api-e2e-smoke api-e2e-cloud localmode-e2e lint tidy check clean help
.PHONY: all build local test api-e2e-smoke api-e2e-cloud localmode-e2e lint tidy check clean help

all: build

build: ## Build the volcano binary into ./$(BINARY)
go build -ldflags '$(LDFLAGS)' -o $(BINARY) ./cmd/volcano

local: ## Build volcano using variables loaded from .env.local
@if [ ! -f .env.local ]; then \
echo ".env.local not found. Create one with VOLCANO_WEB_URL=... and VOLCANO_API_URL=..."; \
exit 1; \
fi; \
set -a; source .env.local; set +a; \
if [ -z "$${FIRST_PARTY_DEVICE_CLIENT_ID:-}" ] && [ -n "$${VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID:-}" ]; then \
export FIRST_PARTY_DEVICE_CLIENT_ID="$${VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID}"; \
fi; \
if [ -z "$${DEFAULT_API_URL:-}" ] && [ -n "$${VOLCANO_API_URL:-}" ]; then \
export DEFAULT_API_URL="$${VOLCANO_API_URL}"; \
fi; \
if [ -z "$${DEFAULT_WEB_URL:-}" ] && [ -n "$${VOLCANO_WEB_URL:-}" ]; then \
export DEFAULT_WEB_URL="$${VOLCANO_WEB_URL}"; \
fi; \
: "Fall back to the conventional local Volcano Web port (3000) when pointing at a loopback API without an explicit web URL"; \
if [ -z "$${DEFAULT_WEB_URL:-}" ] && [[ "$${VOLCANO_API_URL:-}" == http://localhost:* || "$${VOLCANO_API_URL:-}" == http://127.0.0.1:* ]]; then \
export DEFAULT_WEB_URL="http://localhost:3000"; \
fi; \
$(MAKE) build

test: ## Run unit tests
go test ./...

Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,47 @@ files, migrations directory, and README). Use a template to add
language-specific files: `javascript` (aliases: `js`, `node`, `nodejs`),
`nextjs`, `python`, or `ruby`.

## Authentication

New to Volcano? Create an account from the CLI:

```bash
volcano signup
```

`volcano signup` prefills your email from `git config --global user.email`
when available (press Enter to accept, or type a different address), then
opens Volcano's web signup flow in your browser. Once you finish in the
browser, the CLI completes the device-authorization handshake and saves your
credentials to `~/.volcano/config.json` — so a single command signs you up
**and** logs you in.

Already have an account? Authenticate with `volcano login`:

```bash
# Browser-based login (default)
volcano login

# Token-based login (for CI/CD)
volcano login --token pk-xxxxxxxxxx

# Or skip login entirely with an environment variable
export VOLCANO_TOKEN=pk-xxxxxxxxxx
```

Log out at any time (this deletes local credentials but does not revoke the
token — revoke it in the Volcano dashboard to fully cut off access):

```bash
volcano logout
```

To target a non-production environment, set `VOLCANO_API_URL` to that backend's
API endpoint. `volcano signup` then opens the signup page on the **same**
environment the API points at — it follows the device-flow verification URL,
just like `volcano login` — so you normally don't need anything else. Set
`VOLCANO_WEB_URL` only to force a specific web origin.

## Project configuration (`volcano-config.yaml`)

`volcano config deploy` reconciles declarative project configuration
Expand Down
60 changes: 60 additions & 0 deletions internal/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package api

import (
"context"
"errors"
"fmt"
"net/url"
"strings"

"github.com/Kong/volcano-cli/internal/apiclient"
Expand Down Expand Up @@ -81,3 +83,61 @@ func (c *Client) ExchangePlatformToken(ctx context.Context, authAccessToken, cli
}
return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON403)
}

// WebSignupURL builds the Volcano Web signup URL used by the CLI signup flow.
func WebSignupURL(webURL, email, next string) (string, error) {
webURL = strings.TrimRight(strings.TrimSpace(webURL), "/")
if webURL == "" {
return "", errors.New("web url cannot be empty")
}
parsed, err := url.Parse(webURL)
if err != nil {
return "", fmt.Errorf("failed to parse web url: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", errors.New("web url must use http:// or https:// scheme")
}
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/signup"
query := parsed.Query()
if email = strings.TrimSpace(email); email != "" {
query.Set("email", email)
}
if next = strings.TrimSpace(next); next != "" {
query.Set("next", next)
}
query.Set("source", "cli")
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}

// VerificationWebTarget extracts the web origin and device-approval path advertised
// by a device-authorization response. The signup flow uses these so the browser is
// sent to the same environment that issued the device code — mirroring how login
// follows the API's verification URI. Both values are empty when the response does
// not carry a usable verification URI.
func VerificationWebTarget(deviceAuth *apiclient.DeviceAuthorizationResponse) (origin, devicePath string) {
if deviceAuth == nil {
return "", ""
}
base, err := url.Parse(strings.TrimSpace(deviceAuth.VerificationUri))
if err != nil || base.Scheme == "" || base.Host == "" {
return "", ""
}
origin = base.Scheme + "://" + base.Host

// verification_uri_complete already carries the prefilled user_code, so prefer it.
if complete, err := url.Parse(strings.TrimSpace(deviceAuth.VerificationUriComplete)); err == nil && complete.Path != "" {
return origin, complete.RequestURI()
}

devicePath = base.Path
if devicePath == "" {
devicePath = "/device"
}
if userCode := strings.TrimSpace(deviceAuth.UserCode); userCode != "" {
values := base.Query()
values.Set("user_code", userCode)
devicePath += "?" + values.Encode()
}
return origin, devicePath
}
58 changes: 58 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/Kong/volcano-cli/internal/apiclient"
)

func TestPollDeviceTokenUnexpectedStatusReturnsError(t *testing.T) {
Expand Down Expand Up @@ -50,6 +52,62 @@ func TestStartDeviceAuthorizationNormalizesOAuthError(t *testing.T) {
require.ErrorContains(t, err, "HTTP 400: client_id is required")
}

func TestWebSignupURL(t *testing.T) {
signupURL, err := WebSignupURL("http://localhost:3000", " ted@example.com ", "/device?user_code=ABCD-EFGH")
require.NoError(t, err)
assert.Equal(t, "http://localhost:3000/signup?email=ted%40example.com&next=%2Fdevice%3Fuser_code%3DABCD-EFGH&source=cli", signupURL)
}

func TestVerificationWebTarget(t *testing.T) {
t.Run("prefers complete uri", func(t *testing.T) {
origin, devicePath := VerificationWebTarget(&apiclient.DeviceAuthorizationResponse{
VerificationUri: "https://volcano.dev/device",
VerificationUriComplete: "https://volcano.dev/device?user_code=ABCD-EFGH",
UserCode: "ABCD-EFGH",
})
assert.Equal(t, "https://volcano.dev", origin)
assert.Equal(t, "/device?user_code=ABCD-EFGH", devicePath)
})
t.Run("falls back to base uri and attaches user code", func(t *testing.T) {
origin, devicePath := VerificationWebTarget(&apiclient.DeviceAuthorizationResponse{
VerificationUri: "http://localhost:3000/device",
UserCode: "WXYZ-1234",
})
assert.Equal(t, "http://localhost:3000", origin)
assert.Equal(t, "/device?user_code=WXYZ-1234", devicePath)
})
t.Run("empty when no verification uri", func(t *testing.T) {
origin, devicePath := VerificationWebTarget(&apiclient.DeviceAuthorizationResponse{})
assert.Empty(t, origin)
assert.Empty(t, devicePath)
})
}

func TestWebSignupURLOmitsEmptyEmailAndNext(t *testing.T) {
signupURL, err := WebSignupURL("https://volcano.dev/", " ", " ")
require.NoError(t, err)
assert.Equal(t, "https://volcano.dev/signup?source=cli", signupURL)
}

func TestWebSignupURLRejectsBadInput(t *testing.T) {
tests := []struct {
name string
webURL string
wantErr string
}{
{name: "empty", webURL: " ", wantErr: "web url cannot be empty"},
{name: "missing scheme", webURL: "volcano.dev", wantErr: "must use http:// or https://"},
{name: "non-http scheme", webURL: "ftp://volcano.dev", wantErr: "must use http:// or https://"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
signupURL, err := WebSignupURL(tt.webURL, "ted@example.com", "/device")
assert.Empty(t, signupURL)
require.ErrorContains(t, err, tt.wantErr)
})
}
}

func TestNewClientPreservesAPIURLPathPrefix(t *testing.T) {
var sawPath string
var sawQuery string
Expand Down
69 changes: 60 additions & 9 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,58 @@ func (s Service) LoginWithToken(ctx context.Context, cfg *config.Config, token s
return Credentials{Token: token}, nil
}

// Signup runs the same device flow as login but routes the browser through
// Volcano Web's signup page first. The signup origin is taken from the device
// authorization response's verification URI, so signup always targets the same
// environment as login; an explicit VOLCANO_WEB_URL still wins.
func (s Service) Signup(ctx context.Context, cfg *config.Config, email string, w io.Writer) (Credentials, error) {
apiURL := s.apiURL(cfg)
clientID, err := resolveDeviceClientID(apiURL)
if err != nil {
return Credentials{}, err
}
// Fail fast on an explicitly misconfigured VOLCANO_WEB_URL before allocating a
// device code, instead of burning a device authorization.
webOverride, hasWebOverride := cfg.WebURLOverride()
if hasWebOverride {
if _, err := api.WebSignupURL(webOverride, email, ""); err != nil {
return Credentials{}, err
}
}
client, err := s.sessions.APIClient(apiURL, "")
if err != nil {
return Credentials{}, err
}

deviceAuth, err := client.StartDeviceAuthorization(ctx, clientID)
if err != nil {
return Credentials{}, err
}

// Follow the backend the device flow points at, exactly like login does.
webURL, devicePath := api.VerificationWebTarget(deviceAuth)
if hasWebOverride {
webURL = webOverride
}
if webURL == "" {
webURL = cfg.WebURL()
}
if devicePath == "" {
devicePath = "/device"
if userCode := strings.TrimSpace(deviceAuth.UserCode); userCode != "" {
devicePath = "/device?" + url.Values{"user_code": []string{userCode}}.Encode()
}
}

signupURL, err := api.WebSignupURL(webURL, email, devicePath)
if err != nil {
return Credentials{}, err
}

fmt.Fprintln(w, "\nInitiating browser signup...")
return s.completeBrowserLogin(ctx, client, clientID, deviceAuth, w, signupURL)
}

// LoginWithBrowser runs the OAuth device flow and returns credentials to persist.
func (s Service) LoginWithBrowser(ctx context.Context, cfg *config.Config, w io.Writer) (Credentials, error) {
apiURL := s.apiURL(cfg)
Expand All @@ -75,7 +127,11 @@ func (s Service) LoginWithBrowser(ctx context.Context, cfg *config.Config, w io.
}

fmt.Fprintln(w, "\nInitiating browser authentication...")
return s.completeBrowserLogin(ctx, client, clientID, deviceAuth, w)
verificationURL := strings.TrimSpace(deviceAuth.VerificationUriComplete)
if verificationURL == "" {
verificationURL = strings.TrimSpace(deviceAuth.VerificationUri)
}
return s.completeBrowserLogin(ctx, client, clientID, deviceAuth, w, verificationURL)
}

// resolveDeviceClientID returns the device OAuth client id for the login flow.
Expand Down Expand Up @@ -117,16 +173,11 @@ func (s Service) apiURL(cfg *config.Config) string {
return s.sessions.APIURL(cfg)
}

func (s Service) completeBrowserLogin(ctx context.Context, client *api.Client, clientID string, deviceAuth *apiclient.DeviceAuthorizationResponse, w io.Writer) (Credentials, error) {
verificationURL := strings.TrimSpace(deviceAuth.VerificationUriComplete)
if verificationURL == "" {
verificationURL = strings.TrimSpace(deviceAuth.VerificationUri)
}

func (s Service) completeBrowserLogin(ctx context.Context, client *api.Client, clientID string, deviceAuth *apiclient.DeviceAuthorizationResponse, w io.Writer, browserURL string) (Credentials, error) {
fmt.Fprintf(w, "\nCode: %s\n", deviceAuth.UserCode)
fmt.Fprintf(w, "Opening browser: %s\n", verificationURL)
fmt.Fprintf(w, "Opening browser: %s\n", browserURL)

if err := cliruntime.OpenBrowser(s.deps, verificationURL); err != nil { //nolint:contextcheck // browser launch is fire-and-forget; auth ctx would cancel the spawned browser
if err := cliruntime.OpenBrowser(s.deps, browserURL); err != nil { //nolint:contextcheck // browser launch is fire-and-forget; auth ctx would cancel the spawned browser
fmt.Fprintln(w, "\n(If browser didn't open, visit the URL above)")
}

Expand Down
Loading
Loading