Skip to content

Commit 76daab2

Browse files
authored
Handle OAuth denial and deduplicate login output (#260)
## Summary OAuth consent denial returns `error=access_denied` without a code. The CLI previously treated that valid error response as a malformed success callback, showing “missing authorization code” in the browser. Native login also emitted duplicate terminal statuses and reported success before credentials were persisted. - Handle OAuth errors after state validation and before requiring a code. Denial returns a distinct error without entering token exchange. - Reuse the callback page layout for a clear branded denial page without a success checkmark. Provider-supplied error descriptions are not reflected. - Stop and erase the waiting spinner without printing a second status. The root renderer now emits the single meaningful denial, cancellation, or authentication error with a nonzero exit. - Classify cancellation from the authentication error itself so a racing context cancellation cannot mask an unrelated failure. - Print one success only after credentials are saved. Credential-save failures retain the storage error and reauthentication guidance and now exit nonzero. - Preserve existing stored credentials on denial. - Extract the callback and root error handlers for focused rendered-output regression coverage. ## Validation - `go test ./pkg/auth ./cmd -count=1` - `go test -race ./pkg/auth -count=1` - `go vet ./pkg/auth ./cmd` - `go build ./...` - Focused output tests cover success, consent denial, generic authentication failure, cancellation races, credential-save failure, and the real spinner erase boundary. - Earlier built-binary loopback fixture: simulated browser launch and denial callback produced one clear terminal error, exit code 1, zero token requests, and unchanged fixture credentials. This used isolated fixture configuration with keyring access disabled, not a live provider login. - Chromium rendering at desktop and mobile widths: denial text, embedded favicon, no success checkmark, and no horizontal overflow. Existing callback layout and colors are retained. - Build, test, Semgrep, Socket Security, and BugBot checks pass on `d99e4da9b708e119211d76dec6e5047305d8858f`. This is not released; a live denial retest is still pending after release. --------- Co-authored-by: rgarcia <72655+rgarcia@users.noreply.github.com>
1 parent 7792305 commit 76daab2

8 files changed

Lines changed: 550 additions & 150 deletions

File tree

cmd/login.go

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"os"
78
"os/signal"
@@ -55,32 +56,41 @@ func runLogin(cmd *cobra.Command, args []string) error {
5556

5657
pterm.Debug.Printf("Starting local callback server on %s\n", oauthConfig.Config.RedirectURL)
5758

58-
// Start OAuth flow
59-
spinner, _ := pterm.DefaultSpinner.Start("Waiting for authentication...")
60-
tokens, err := oauthConfig.StartOAuthFlow(ctx)
61-
if err != nil {
62-
spinner.Fail("Authentication failed")
59+
spinner, _ := newLoginSpinner().Start("Waiting for authentication...")
60+
return completeLogin(ctx, spinner, oauthConfig.StartOAuthFlow, auth.SaveTokens)
61+
}
6362

64-
// Handle common error cases with helpful messages
65-
if ctx.Err() == context.Canceled {
66-
pterm.Info.Println("Authentication cancelled by user")
67-
return nil
68-
}
63+
func newLoginSpinner() *pterm.SpinnerPrinter {
64+
return pterm.DefaultSpinner.WithRemoveWhenDone()
65+
}
66+
67+
type spinnerStopper interface {
68+
Stop() error
69+
}
6970

71+
func completeLogin(
72+
ctx context.Context,
73+
spinner spinnerStopper,
74+
authenticate func(context.Context) (*auth.TokenStorage, error),
75+
saveTokens func(*auth.TokenStorage) error,
76+
) error {
77+
tokens, err := authenticate(ctx)
78+
_ = spinner.Stop()
79+
if err != nil {
80+
if errors.Is(err, auth.ErrAuthorizationDenied) {
81+
return err
82+
}
83+
if errors.Is(err, context.Canceled) {
84+
return errors.New("authentication cancelled by user")
85+
}
7086
return fmt.Errorf("authentication failed: %w", err)
7187
}
7288

73-
spinner.Success("Authentication successful!")
74-
75-
// Save tokens securely
76-
if err := auth.SaveTokens(tokens); err != nil {
77-
pterm.Warning.Printf("Authentication succeeded but failed to save credentials: %v\n", err)
78-
pterm.Warning.Println("You may need to re-authenticate on your next CLI usage")
79-
return nil
89+
if err := saveTokens(tokens); err != nil {
90+
return fmt.Errorf("OAuth authorization completed, but credentials could not be saved: %w; fix credential storage and run 'kernel login' again", err)
8091
}
8192

82-
pterm.Success.Println("Successfully authenticated with Kernel!")
93+
pterm.Success.Println("Successfully authenticated with Kernel!")
8394
pterm.Info.Println("You can now use other Kernel CLI commands without setting KERNEL_API_KEY")
84-
8595
return nil
8696
}

cmd/login_test.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"strings"
8+
"testing"
9+
10+
"github.com/charmbracelet/fang"
11+
"github.com/charmbracelet/lipgloss/v2"
12+
"github.com/kernel/cli/pkg/auth"
13+
"github.com/pterm/pterm"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
type testSpinner struct {
19+
stops int
20+
}
21+
22+
func (s *testSpinner) Stop() error {
23+
s.stops++
24+
return nil
25+
}
26+
27+
func TestCompleteLoginOutput(t *testing.T) {
28+
tests := []struct {
29+
name string
30+
authErr error
31+
saveErr error
32+
cancel bool
33+
wantErr bool
34+
wantSave bool
35+
wantSuccess int
36+
wantErrors int
37+
wantOutput []string
38+
absentOutput []string
39+
}{
40+
{
41+
name: "success",
42+
wantSave: true,
43+
wantSuccess: 1,
44+
wantOutput: []string{
45+
"Successfully authenticated with Kernel!",
46+
"You can now use other Kernel CLI commands without setting KERNEL_API_KEY",
47+
},
48+
absentOutput: []string{"Authentication successful!", "ERROR"},
49+
},
50+
{
51+
name: "consent denial",
52+
authErr: auth.ErrAuthorizationDenied,
53+
wantErr: true,
54+
wantErrors: 1,
55+
wantOutput: []string{"authorization denied; no new credentials were saved"},
56+
absentOutput: []string{
57+
"Authentication failed",
58+
"Successfully authenticated",
59+
},
60+
},
61+
{
62+
name: "generic authentication failure",
63+
authErr: errors.New("callback server failed"),
64+
wantErr: true,
65+
wantErrors: 1,
66+
wantOutput: []string{"authentication failed: callback server failed"},
67+
absentOutput: []string{
68+
"Successfully authenticated",
69+
},
70+
},
71+
{
72+
name: "credential save failure",
73+
saveErr: errors.New("credential store unavailable"),
74+
wantErr: true,
75+
wantSave: true,
76+
wantErrors: 1,
77+
wantOutput: []string{
78+
"OAuth authorization completed, but credentials could not be saved: credential store unavailable",
79+
"fix credential storage and run 'kernel login' again",
80+
},
81+
absentOutput: []string{"SUCCESS", "Successfully authenticated"},
82+
},
83+
{
84+
name: "cancellation",
85+
authErr: context.Canceled,
86+
cancel: true,
87+
wantErr: true,
88+
wantErrors: 1,
89+
wantOutput: []string{"authentication cancelled by user"},
90+
absentOutput: []string{
91+
"authentication failed",
92+
"Successfully authenticated",
93+
},
94+
},
95+
{
96+
name: "ambient cancellation does not mask authentication failure",
97+
authErr: errors.New("callback server failed"),
98+
cancel: true,
99+
wantErr: true,
100+
wantErrors: 1,
101+
wantOutput: []string{"authentication failed: callback server failed"},
102+
absentOutput: []string{
103+
"authentication cancelled",
104+
"Successfully authenticated",
105+
},
106+
},
107+
}
108+
109+
for _, tt := range tests {
110+
t.Run(tt.name, func(t *testing.T) {
111+
output := capturePtermOutput(t)
112+
ctx, cancel := context.WithCancel(context.Background())
113+
if tt.cancel {
114+
cancel()
115+
} else {
116+
defer cancel()
117+
}
118+
119+
spinner := &testSpinner{}
120+
saveCalled := false
121+
err := completeLogin(
122+
ctx,
123+
spinner,
124+
func(context.Context) (*auth.TokenStorage, error) {
125+
return &auth.TokenStorage{}, tt.authErr
126+
},
127+
func(*auth.TokenStorage) error {
128+
saveCalled = true
129+
return tt.saveErr
130+
},
131+
)
132+
133+
if tt.wantErr {
134+
require.Error(t, err)
135+
renderCommandError(output, fang.Styles{
136+
ErrorText: lipgloss.NewStyle(),
137+
Program: fang.Program{Flag: lipgloss.NewStyle()},
138+
}, err)
139+
} else {
140+
require.NoError(t, err)
141+
}
142+
143+
rendered := ansiEscapes.ReplaceAllString(output.String(), "")
144+
assert.Equal(t, 1, spinner.stops)
145+
assert.Equal(t, tt.wantSave, saveCalled)
146+
assert.Equal(t, tt.wantSuccess, strings.Count(rendered, "SUCCESS"), rendered)
147+
assert.Equal(t, tt.wantErrors, strings.Count(rendered, "ERROR"), rendered)
148+
for _, want := range tt.wantOutput {
149+
assert.Contains(t, rendered, want)
150+
}
151+
for _, absent := range tt.absentOutput {
152+
assert.NotContains(t, rendered, absent)
153+
}
154+
})
155+
}
156+
}
157+
158+
func TestLoginSpinnerClearsWaitingOutput(t *testing.T) {
159+
rawOutput := pterm.RawOutput
160+
pterm.RawOutput = false
161+
t.Cleanup(func() { pterm.RawOutput = rawOutput })
162+
163+
var output bytes.Buffer
164+
spinner := newLoginSpinner().WithWriter(&output)
165+
spinner.IsActive = true
166+
spinner.UpdateText("Waiting for authentication...")
167+
require.Contains(t, output.String(), "Waiting for authentication...")
168+
169+
require.NoError(t, spinner.Stop())
170+
assert.NotContains(t, visibleTerminalOutput(output.String()), "Waiting for authentication...")
171+
}
172+
173+
func visibleTerminalOutput(output string) string {
174+
var visible strings.Builder
175+
line := make([]rune, 0, len(output))
176+
cursor := 0
177+
for _, r := range output {
178+
switch r {
179+
case '\r':
180+
cursor = 0
181+
case '\n':
182+
visible.WriteString(string(line))
183+
visible.WriteRune('\n')
184+
line = line[:0]
185+
cursor = 0
186+
default:
187+
if cursor == len(line) {
188+
line = append(line, r)
189+
} else {
190+
line[cursor] = r
191+
}
192+
cursor++
193+
}
194+
}
195+
visible.WriteString(string(line))
196+
return visible.String()
197+
}

cmd/root.go

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -221,57 +221,59 @@ func Execute(m Metadata) {
221221
if err := fang.Execute(context.Background(), rootCmd,
222222
fang.WithVersion(metadata.Version),
223223
fang.WithCommit(metadata.Commit),
224-
fang.WithErrorHandler(func(w io.Writer, styles fang.Styles, err error) {
225-
err = util.CleanedUpSdkError{Err: err}
226-
227-
// Some subcommands intentionally suppress diagnostics for curl-like
228-
// quiet modes while still returning a non-zero exit status.
229-
var silent interface{ Silent() bool }
230-
if errors.As(err, &silent) && silent.Silent() {
231-
return
232-
}
233-
234-
// remove margins so that it matches other pterm.error "style"
235-
// we should add them back later as it looks cleaner
236-
errorTextStyle := styles.ErrorText.UnsetMargins()
237-
238-
// Keep command errors on fang's error stream, normally stderr. This
239-
// gives curl-like commands a quiet stdout for response bodies and
240-
// scripts while preserving the existing pterm error styling.
241-
oldErrorWriter := pterm.Error.Writer
242-
pterm.Error.Writer = w
243-
defer func() {
244-
pterm.Error.Writer = oldErrorWriter
245-
}()
246-
// Fail-fast interactivity errors render one problem per line.
247-
// The default ErrorText style must not apply: its width-based
248-
// word-wrap splits flag tokens like --template across lines, and
249-
// its transform (strings.Fields + Join) collapses the newlines
250-
// between problems.
251-
msg := strings.TrimSpace(err.Error())
252-
style := errorTextStyle
253-
var promptErr *interactive.PromptError
254-
if errors.As(err, &promptErr) {
255-
msg = capitalizeFirst(promptErr.Display())
256-
style = style.UnsetWidth().UnsetTransform()
257-
}
258-
pterm.Error.Println(style.Render(msg))
259-
if isUsageError(err) {
260-
fmt.Fprintln(w)
261-
fmt.Fprintln(w, lipgloss.JoinHorizontal(
262-
lipgloss.Left,
263-
errorTextStyle.UnsetWidth().Render("Try"),
264-
styles.Program.Flag.Render("--help"),
265-
errorTextStyle.UnsetWidth().UnsetTransform().PaddingLeft(1).Render("for usage."),
266-
))
267-
}
268-
}),
224+
fang.WithErrorHandler(renderCommandError),
269225
); err != nil {
270226
// fang takes care of printing the error
271227
os.Exit(1)
272228
}
273229
}
274230

231+
func renderCommandError(w io.Writer, styles fang.Styles, err error) {
232+
err = util.CleanedUpSdkError{Err: err}
233+
234+
// Some subcommands intentionally suppress diagnostics for curl-like
235+
// quiet modes while still returning a non-zero exit status.
236+
var silent interface{ Silent() bool }
237+
if errors.As(err, &silent) && silent.Silent() {
238+
return
239+
}
240+
241+
// remove margins so that it matches other pterm.error "style"
242+
// we should add them back later as it looks cleaner
243+
errorTextStyle := styles.ErrorText.UnsetMargins()
244+
245+
// Keep command errors on fang's error stream, normally stderr. This
246+
// gives curl-like commands a quiet stdout for response bodies and
247+
// scripts while preserving the existing pterm error styling.
248+
oldErrorWriter := pterm.Error.Writer
249+
pterm.Error.Writer = w
250+
defer func() {
251+
pterm.Error.Writer = oldErrorWriter
252+
}()
253+
// Fail-fast interactivity errors render one problem per line.
254+
// The default ErrorText style must not apply: its width-based
255+
// word-wrap splits flag tokens like --template across lines, and
256+
// its transform (strings.Fields + Join) collapses the newlines
257+
// between problems.
258+
msg := strings.TrimSpace(err.Error())
259+
style := errorTextStyle
260+
var promptErr *interactive.PromptError
261+
if errors.As(err, &promptErr) {
262+
msg = capitalizeFirst(promptErr.Display())
263+
style = style.UnsetWidth().UnsetTransform()
264+
}
265+
pterm.Error.Println(style.Render(msg))
266+
if isUsageError(err) {
267+
fmt.Fprintln(w)
268+
fmt.Fprintln(w, lipgloss.JoinHorizontal(
269+
lipgloss.Left,
270+
errorTextStyle.UnsetWidth().Render("Try"),
271+
styles.Program.Flag.Render("--help"),
272+
errorTextStyle.UnsetWidth().UnsetTransform().PaddingLeft(1).Render("for usage."),
273+
))
274+
}
275+
}
276+
275277
// isUsageError is a hack to detect usage errors.
276278
// See: https://github.com/spf13/cobra/pull/2266
277279
// from github.com/charmbracelet/fang/help.go

0 commit comments

Comments
 (0)