From b2ed17b728e5721b4fb1bb451ea207d4c4438d20 Mon Sep 17 00:00:00 2001 From: tomaskir Date: Sun, 28 Jun 2026 18:57:53 +0000 Subject: [PATCH 1/2] feat(auth): support custom token name and lifetime in webauth login phase auth (webauth mode) always minted a non-expiring PAT named username@hostname, with no way to override either. Add two optional flags and send the values to the Console in the webauth request payload: - --token-name sets the PAT name (default username@hostname). This fixes unhelpful names under Docker, where the default becomes root@. - --token-lifetime sets the PAT lifetime, e.g. 7d, 12h, 30m, 60s, 2w (default: never expires). The webauth payload moves from the hyphen-joined port-pubKeyHex-patName string to base64(JSON) { port, publicKey, name, lifetime? }, where lifetime is in seconds. This is parse-safe for names containing hyphens or other characters and matches the Console webauth page contract. Add util.ParseTokenLifetime to convert a lifetime string into seconds. Console side: phasehq/console#928, phasehq/console#937 (PR phasehq/console#938). Closes #302 Closes #303 --- src/cmd/auth.go | 2 ++ src/cmd/auth_webauth.go | 53 +++++++++++++++++++++++++++--- src/cmd/auth_webauth_test.go | 62 ++++++++++++++++++++++++++++++++++++ src/pkg/util/misc.go | 40 +++++++++++++++++++++++ src/pkg/util/misc_test.go | 29 +++++++++++++++++ 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 src/cmd/auth_webauth_test.go diff --git a/src/cmd/auth.go b/src/cmd/auth.go index 2ed2e712..fe4c8366 100644 --- a/src/cmd/auth.go +++ b/src/cmd/auth.go @@ -29,6 +29,8 @@ func init() { authCmd.Flags().Int("ttl", 0, "Token TTL in seconds (for external identity modes)") authCmd.Flags().Bool("no-store", false, "Print token to stdout instead of storing (for external identity modes)") authCmd.Flags().String("azure-resource", "", "Azure AD resource/audience for token request (for azure mode, default: https://management.azure.com/)") + authCmd.Flags().String("token-name", "", "Name for the personal access token (webauth mode, default: username@hostname)") + authCmd.Flags().String("token-lifetime", "", "Lifetime for the personal access token, e.g. 7d, 12h, 30m (webauth mode, default: never expires)") rootCmd.AddCommand(authCmd) } diff --git a/src/cmd/auth_webauth.go b/src/cmd/auth_webauth.go index 780e72ec..d0890790 100644 --- a/src/cmd/auth_webauth.go +++ b/src/cmd/auth_webauth.go @@ -21,6 +21,39 @@ import ( "github.com/spf13/cobra" ) +// webAuthPayload is the request payload the Console webauth page parses. It is sent +// as base64(JSON) in the webauth URL. Lifetime is the requested token lifetime in +// seconds; when 0 it is omitted and the token never expires. +type webAuthPayload struct { + Port int `json:"port"` + PublicKey string `json:"publicKey"` + Name string `json:"name"` + Lifetime int64 `json:"lifetime,omitempty"` +} + +// resolveTokenName returns the requested token name: the trimmed flag value when +// set, otherwise the default username@hostname. +func resolveTokenName(flagValue, username, hostname string) string { + if name := strings.TrimSpace(flagValue); name != "" { + return name + } + return fmt.Sprintf("%s@%s", username, hostname) +} + +// encodeWebAuthPayload serializes the webauth request payload as base64(JSON). +func encodeWebAuthPayload(port int, pubKeyHex, name string, lifetimeSeconds int64) (string, error) { + rawData, err := json.Marshal(webAuthPayload{ + Port: port, + PublicKey: pubKeyHex, + Name: name, + Lifetime: lifetimeSeconds, + }) + if err != nil { + return "", fmt.Errorf("failed to encode webauth payload: %w", err) + } + return base64.StdEncoding.EncodeToString(rawData), nil +} + func runWebAuth(cmd *cobra.Command, host string) error { // Pick random port port := 8002 + rand.Intn(12001) @@ -34,17 +67,27 @@ func runWebAuth(cmd *cobra.Command, host string) error { pubKeyHex := hex.EncodeToString(kp.PublicKey[:]) privKeyHex := hex.EncodeToString(kp.SecretKey[:]) - // Build PAT name + // Build PAT name (default username@hostname, overridable via --token-name) username := "unknown" if u, err := user.Current(); err == nil { username = u.Username } hostname, _ := os.Hostname() - patName := fmt.Sprintf("%s@%s", username, hostname) + tokenNameFlag, _ := cmd.Flags().GetString("token-name") + patName := resolveTokenName(tokenNameFlag, username, hostname) + + // Parse the requested token lifetime (default: never expires) + lifetimeStr, _ := cmd.Flags().GetString("token-lifetime") + lifetimeSeconds, err := util.ParseTokenLifetime(lifetimeStr) + if err != nil { + return err + } - // Encode payload - rawData := fmt.Sprintf("%d-%s-%s", port, pubKeyHex, patName) - encoded := base64.StdEncoding.EncodeToString([]byte(rawData)) + // Encode payload as base64(JSON): { port, publicKey, name, lifetime? } + encoded, err := encodeWebAuthPayload(port, pubKeyHex, patName, lifetimeSeconds) + if err != nil { + return err + } // Channel to receive auth data type authData struct { diff --git a/src/cmd/auth_webauth_test.go b/src/cmd/auth_webauth_test.go new file mode 100644 index 00000000..4b44dffe --- /dev/null +++ b/src/cmd/auth_webauth_test.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "encoding/base64" + "encoding/json" + "testing" +) + +func TestResolveTokenName(t *testing.T) { + if got := resolveTokenName("", "alice", "laptop"); got != "alice@laptop" { + t.Fatalf("empty flag: got %q, want %q", got, "alice@laptop") + } + if got := resolveTokenName(" ", "alice", "laptop"); got != "alice@laptop" { + t.Fatalf("whitespace flag: got %q, want %q", got, "alice@laptop") + } + if got := resolveTokenName(" ci-prod-api ", "alice", "laptop"); got != "ci-prod-api" { + t.Fatalf("set flag: got %q, want %q", got, "ci-prod-api") + } +} + +func TestEncodeWebAuthPayload(t *testing.T) { + // With a lifetime: all fields present, name with hyphens preserved. + encoded, err := encodeWebAuthPayload(8002, "abc123", "ci-prod-api", 604800) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("payload is not valid base64: %v", err) + } + + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + if fields["port"].(float64) != 8002 { + t.Fatalf("port: got %v, want 8002", fields["port"]) + } + if fields["publicKey"] != "abc123" { + t.Fatalf("publicKey: got %v, want abc123", fields["publicKey"]) + } + if fields["name"] != "ci-prod-api" { + t.Fatalf("name: got %v, want ci-prod-api", fields["name"]) + } + if fields["lifetime"].(float64) != 604800 { + t.Fatalf("lifetime: got %v, want 604800", fields["lifetime"]) + } + + // Without a lifetime (0): the field is omitted so the token never expires. + encoded, err = encodeWebAuthPayload(8002, "abc123", "alice@laptop", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + raw, _ = base64.StdEncoding.DecodeString(encoded) + fields = map[string]any{} + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + if _, ok := fields["lifetime"]; ok { + t.Fatalf("lifetime should be omitted when zero, got %v", fields["lifetime"]) + } +} diff --git a/src/pkg/util/misc.go b/src/pkg/util/misc.go index 45303a69..8b8e8a22 100644 --- a/src/pkg/util/misc.go +++ b/src/pkg/util/misc.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "runtime" + "strconv" "strings" sdk "github.com/phasehq/golang-sdk/v2/phase" @@ -96,6 +97,45 @@ func GetShellCommand(shellType string) ([]string, error) { return []string{path}, nil } +// ParseTokenLifetime parses a token lifetime string such as "7d", "12h", "30m", "60s" +// or "2w" into a number of seconds. Supported units are s (seconds), m (minutes), +// h (hours), d (days) and w (weeks). An empty string returns 0, meaning the token +// never expires. +func ParseTokenLifetime(lifetime string) (int64, error) { + lifetime = strings.TrimSpace(strings.ToLower(lifetime)) + if lifetime == "" { + return 0, nil + } + + invalid := fmt.Errorf("invalid token lifetime %q (expected a number and a unit, e.g. 7d, 12h, 30m, 60s, 2w)", lifetime) + if len(lifetime) < 2 { + return 0, invalid + } + + value, err := strconv.ParseInt(lifetime[:len(lifetime)-1], 10, 64) + if err != nil || value < 0 { + return 0, invalid + } + + var perUnit int64 + switch lifetime[len(lifetime)-1] { + case 's': + perUnit = 1 + case 'm': + perUnit = 60 + case 'h': + perUnit = 3600 + case 'd': + perUnit = 86400 + case 'w': + perUnit = 604800 + default: + return 0, invalid + } + + return value * perUnit, nil +} + // ValidateURL checks that a URL has both a scheme (e.g. https) and a host (e.g. example.com). func ValidateURL(rawURL string) bool { parsed, err := url.Parse(rawURL) diff --git a/src/pkg/util/misc_test.go b/src/pkg/util/misc_test.go index 28c97327..1eeea9f4 100644 --- a/src/pkg/util/misc_test.go +++ b/src/pkg/util/misc_test.go @@ -50,6 +50,35 @@ func TestParseBoolFlag(t *testing.T) { } } +func TestParseTokenLifetime(t *testing.T) { + valid := map[string]int64{ + "": 0, + "60s": 60, + "30m": 1800, + "12h": 43200, + "7d": 604800, + "2w": 1209600, + " 7D ": 604800, // trimmed and case-insensitive + "0d": 0, + } + for in, want := range valid { + got, err := ParseTokenLifetime(in) + if err != nil { + t.Fatalf("unexpected error for %q: %v", in, err) + } + if got != want { + t.Fatalf("ParseTokenLifetime(%q) = %d, want %d", in, got, want) + } + } + + invalid := []string{"7", "d", "7x", "-7d", "abc", "1.5h", "7 d"} + for _, in := range invalid { + if _, err := ParseTokenLifetime(in); err == nil { + t.Fatalf("expected error for %q", in) + } + } +} + func TestParseEnvFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".env") From 66b6c9fd452d3076f20822062526ff062337f150 Mon Sep 17 00:00:00 2001 From: tomaskir Date: Mon, 29 Jun 2026 01:46:23 +0000 Subject: [PATCH 2/2] feat(auth): allow pinning the webauth callback port Browser login (--mode webauth) starts a local HTTP callback server on a random port (8002-20002), so the port cannot be known ahead of time. That breaks webauth inside containers, where Docker port publishing (-p) needs a fixed port; the only workaround was --network=host, which is Linux-only and inconsistent on Docker Desktop. Add a --webauth-port flag and a matching PHASE_WEBAUTH_PORT env var to pin the callback server to a caller-specified port. The resolved port is used both for net.Listen and in the webauth payload's port field, so the Console redirects back to the same fixed port: phase auth --mode webauth --webauth-port 8002 docker run -p 8002:8002 ... phase auth --mode webauth --webauth-port 8002 Precedence is flag, then env var, then the existing random port. Omitting both keeps today's behavior, so this is fully backward compatible. Port resolution is extracted into a pure resolveWebAuthPort helper with unit tests covering the flag/env/random and validation paths. Closes #305 --- src/cmd/auth.go | 1 + src/cmd/auth_webauth.go | 35 +++++++++++++++++++++++++++++++++-- src/cmd/auth_webauth_test.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/cmd/auth.go b/src/cmd/auth.go index fe4c8366..3339fe62 100644 --- a/src/cmd/auth.go +++ b/src/cmd/auth.go @@ -31,6 +31,7 @@ func init() { authCmd.Flags().String("azure-resource", "", "Azure AD resource/audience for token request (for azure mode, default: https://management.azure.com/)") authCmd.Flags().String("token-name", "", "Name for the personal access token (webauth mode, default: username@hostname)") authCmd.Flags().String("token-lifetime", "", "Lifetime for the personal access token, e.g. 7d, 12h, 30m (webauth mode, default: never expires)") + authCmd.Flags().Int("webauth-port", 0, "Fixed local callback port for the browser login flow (webauth mode, also PHASE_WEBAUTH_PORT, default: random)") rootCmd.AddCommand(authCmd) } diff --git a/src/cmd/auth_webauth.go b/src/cmd/auth_webauth.go index d0890790..ca740fa4 100644 --- a/src/cmd/auth_webauth.go +++ b/src/cmd/auth_webauth.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "os/user" + "strconv" "strings" "time" @@ -40,6 +41,28 @@ func resolveTokenName(flagValue, username, hostname string) string { return fmt.Sprintf("%s@%s", username, hostname) } +// resolveWebAuthPort determines an explicit callback-server port for webauth mode from +// the --webauth-port flag (flagSet/flagPort), then the PHASE_WEBAUTH_PORT env value, in +// that order of precedence. ok is false when neither is supplied, signalling the caller +// to fall back to a random port (preserving the historical default behavior). A flag or +// env value outside 1-65535, or a non-numeric env value, is an error. +func resolveWebAuthPort(flagSet bool, flagPort int, envValue string) (port int, ok bool, err error) { + if flagSet { + if flagPort < 1 || flagPort > 65535 { + return 0, false, fmt.Errorf("invalid --webauth-port %d: must be a port between 1 and 65535", flagPort) + } + return flagPort, true, nil + } + if env := strings.TrimSpace(envValue); env != "" { + p, convErr := strconv.Atoi(env) + if convErr != nil || p < 1 || p > 65535 { + return 0, false, fmt.Errorf("invalid PHASE_WEBAUTH_PORT %q: must be a port between 1 and 65535", env) + } + return p, true, nil + } + return 0, false, nil +} + // encodeWebAuthPayload serializes the webauth request payload as base64(JSON). func encodeWebAuthPayload(port int, pubKeyHex, name string, lifetimeSeconds int64) (string, error) { rawData, err := json.Marshal(webAuthPayload{ @@ -55,8 +78,16 @@ func encodeWebAuthPayload(port int, pubKeyHex, name string, lifetimeSeconds int6 } func runWebAuth(cmd *cobra.Command, host string) error { - // Pick random port - port := 8002 + rand.Intn(12001) + // Resolve the callback port: --webauth-port flag, then PHASE_WEBAUTH_PORT, else random. + // A fixed port lets webauth work inside containers where the port must be published ahead of time. + flagPort, _ := cmd.Flags().GetInt("webauth-port") + port, ok, err := resolveWebAuthPort(cmd.Flags().Changed("webauth-port"), flagPort, os.Getenv("PHASE_WEBAUTH_PORT")) + if err != nil { + return err + } + if !ok { + port = 8002 + rand.Intn(12001) + } // Generate ephemeral keypair kp, err := crypto.RandomKeyPair() diff --git a/src/cmd/auth_webauth_test.go b/src/cmd/auth_webauth_test.go index 4b44dffe..7cf24e57 100644 --- a/src/cmd/auth_webauth_test.go +++ b/src/cmd/auth_webauth_test.go @@ -18,6 +18,39 @@ func TestResolveTokenName(t *testing.T) { } } +func TestResolveWebAuthPort(t *testing.T) { + // Neither flag nor env set: caller should fall back to a random port. + if port, ok, err := resolveWebAuthPort(false, 0, ""); ok || err != nil || port != 0 { + t.Fatalf("unset: got (%d, %v, %v), want (0, false, nil)", port, ok, err) + } + + // Flag set: takes precedence and is returned. + if port, ok, err := resolveWebAuthPort(true, 8002, "9000"); !ok || err != nil || port != 8002 { + t.Fatalf("flag set: got (%d, %v, %v), want (8002, true, nil)", port, ok, err) + } + + // Env set, flag unset: env is used (whitespace trimmed). + if port, ok, err := resolveWebAuthPort(false, 0, " 9000 "); !ok || err != nil || port != 9000 { + t.Fatalf("env set: got (%d, %v, %v), want (9000, true, nil)", port, ok, err) + } + + // Out-of-range flag is an error. + if _, ok, err := resolveWebAuthPort(true, 70000, ""); ok || err == nil { + t.Fatalf("out-of-range flag: got (ok=%v, err=%v), want (false, error)", ok, err) + } + if _, ok, err := resolveWebAuthPort(true, 0, ""); ok || err == nil { + t.Fatalf("zero flag: got (ok=%v, err=%v), want (false, error)", ok, err) + } + + // Non-numeric and out-of-range env values are errors. + if _, ok, err := resolveWebAuthPort(false, 0, "abc"); ok || err == nil { + t.Fatalf("non-numeric env: got (ok=%v, err=%v), want (false, error)", ok, err) + } + if _, ok, err := resolveWebAuthPort(false, 0, "70000"); ok || err == nil { + t.Fatalf("out-of-range env: got (ok=%v, err=%v), want (false, error)", ok, err) + } +} + func TestEncodeWebAuthPayload(t *testing.T) { // With a lifetime: all fields present, name with hyphens preserved. encoded, err := encodeWebAuthPayload(8002, "abc123", "ci-prod-api", 604800)