From 66b6c9fd452d3076f20822062526ff062337f150 Mon Sep 17 00:00:00 2001 From: tomaskir Date: Mon, 29 Jun 2026 01:46:23 +0000 Subject: [PATCH] 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)