login: fall back to the device flow in SSH sessions · Entire

login: fall back to the device flow in SSH sessions

4f8ab5e→main·

Soph·1mo ago·3 files·+181 added/-30 removed

Over SSH with a TTY, CanPromptInteractively() is true so the browser flow was chosen — but the loopback listener binds 127.0.0.1 on the remote host, where the user's local browser can't reach it; even the printed fallback URL can't complete. Detect SSH sessions via the SSH_CONNECTION/SSH_CLIENT/SSH_TTY vars sshd sets and route them to the device-code flow with a one-line explanation, the same way gh and gcloud do.

Extract the flow choice from the cobra RunE into runLoginAuto, taking a startBrowser func and the environment facts as plain values, so the selection and its stderr commentary are unit-testable with fakes. The browser-flow integration test blanks the SSH_* vars it inherits from os.Environ() so it keeps exercising the browser path when a developer runs the suite over SSH.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Sessions

4aa664e559dcView transcript

Changes

3

215 unmodified lines

216
217
218
219
219
220
221
222
223
224
225
226
227
228

215 unmodified lines

}))
    defer server.Close()

proc := startLoginProcess(t, server.URL, []string{"ENTIRE_TEST_TTY=1"}, "login", "--insecure-http-auth")
    // Blank the SSH_* vars too: startLoginProcess inherits os.Environ(), so a
    // developer running tests over SSH would otherwise flip the subprocess'
    // isSSHSession() detection and route it to the device flow.
    proc := startLoginProcess(t, server.URL, []string{
        "ENTIRE_TEST_TTY=1",
        "SSH_CONNECTION=", "SSH_CLIENT=", "SSH_TTY=",
    }, "login", "--insecure-http-auth")

authURL := waitForBrowserPrompt(t, proc.stdout)
u, err := url.Parse(authURL)

Mcmd/entire/cli/integration_test/login_test.go+7/-1

73 unmodified lines

74
75
76
77
78
79
80
81
82
83
84
85
86
77
78
79
88
80
81
82
90
83
84
92
93
94
95
85
86
87
88
89
90
43 unmodified lines

134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
148
149
150
151
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183

73 unmodified lines

return err
    }
    client := auth.NewClient(nil, insecureHTTPAuth)
    outW, errW := cmd.OutOrStdout(), cmd.ErrOrStderr()

// Default to the browser (loopback authorization-code) flow:
    // no code to type, no poll latency. It needs a local browser and
    // a reachable 127.0.0.1, so when there's no interactive terminal
    // (CI, piped, SSH without a tty) fall back to the device flow —
    // the same both-flows-with-fallback shape gh / gcloud / aws sso
    // ship. --device forces the device flow explicitly.
    if shouldUseBrowserLogin(useDevice, interactive.CanPromptInteractively()) {
        flow, err := client.StartBrowserAuth(cmd.Context())
    startBrowser := func(ctx context.Context) (browserAuthFlow, error) {
        flow, err := client.StartBrowserAuth(ctx)
        if err != nil {
            return fmt.Errorf("start login: %w", err)
            // Explicit nil so the interface value is nil, not a typed nil.
            return nil, err //nolint:wrapcheck // runLoginAuto wraps this as "start login: %w"
        }
        return runBrowserLogin(cmd.Context(), outW, errW, flow, client.BaseURL(), openBrowser, browserLoginTimeout)
        return flow, nil
    }
    if !useDevice {
        fmt.Fprintln(errW, "No interactive terminal detected; using device-code flow.")
    }
    return runLogin(cmd.Context(), outW, errW, client, openBrowser)
    return runLoginAuto(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(),
        client, startBrowser, openBrowser,
        useDevice, interactive.CanPromptInteractively(), isSSHSession())
},
}
addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth)
43 unmodified lines

return persistLogin(outW, errW, client.BaseURL(), token, refreshToken)

// runLoginAuto picks between the browser (loopback authorization-code) and
// device-code flows and runs the chosen one. The browser flow is the
// default — no code to type, no poll latency — but it needs a browser that
// can reach this machine's 127.0.0.1, so headless terminals (CI, piped
// stdin) and SSH sessions fall back to the device flow with a one-line
// explanation; the same both-flows-with-fallback shape gh / gcloud /
// aws sso ship. --device forces the device flow without commentary.
func runLoginAuto(ctx context.Context, outW, errW io.Writer, deviceClient deviceAuthClient, startBrowser func(context.Context) (browserAuthFlow, error), openURL browserOpenFunc, useDevice, canPrompt, sshSession bool) error {
    if shouldUseBrowserLogin(useDevice, canPrompt, sshSession) {
        flow, err := startBrowser(ctx)
        if err != nil {
            return fmt.Errorf("start login: %w", err)
        }
        return runBrowserLogin(ctx, outW, errW, flow, deviceClient.BaseURL(), openURL, browserLoginTimeout)
    }
    switch {
    case useDevice:
        // Explicitly requested; no explanation needed.
    case !canPrompt:
        fmt.Fprintln(errW, "No interactive terminal detected; using device-code flow.")
    case sshSession:
        fmt.Fprintln(errW, "SSH session detected; using device-code flow (a browser opened here couldn't reach this machine).")
    }
    return runLogin(ctx, outW, errW, deviceClient, openURL)
}

// shouldUseBrowserLogin reports whether `entire login` should use the
// loopback authorization-code (browser) flow. The browser flow is the
// default but needs a local browser + reachable 127.0.0.1, so it's only
// chosen when --device wasn't passed and an interactive terminal is
// present; otherwise the caller falls back to the device flow.
func shouldUseBrowserLogin(useDevice, canPrompt bool) bool {
    return !useDevice && canPrompt
// chosen when --device wasn't passed, an interactive terminal is present,
// and we're not inside an SSH session (where the loopback listener binds
// on the remote host, out of the user's browser's reach); otherwise the
// caller falls back to the device flow.
func shouldUseBrowserLogin(useDevice, canPrompt, sshSession bool) bool {
    return !useDevice && canPrompt && !sshSession
}

// isSSHSession reports whether this process is running inside an SSH
// session: sshd sets SSH_CONNECTION/SSH_CLIENT for every session and
// SSH_TTY for interactive ones.
func isSSHSession() bool {
    return os.Getenv("SSH_CONNECTION") != "" ||
        os.Getenv("SSH_CLIENT") != "" ||
        os.Getenv("SSH_TTY") != ""
}

// runBrowserLogin runs the loopback authorization-code flow on an