Skip to content

Commit be9d146

Browse files
committed
v0.0.5: oauth signin
* switch 'auth signin' to a OAuth device flow * update output formatting
1 parent ea8a175 commit be9d146

File tree

11 files changed

+210
-127
lines changed

11 files changed

+210
-127
lines changed

api/api.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ func (r responseChecker) RoundTrip(req *http.Request) (*http.Response, error) {
8585
return nil, fmt.Errorf("request error %s %s: %w", req.Method, req.URL.Path, err)
8686
}
8787

88-
if res.StatusCode == 500 {
88+
switch res.StatusCode {
89+
case http.StatusForbidden:
90+
return nil, ErrSignedOut
91+
case http.StatusInternalServerError:
8992
return nil, fmt.Errorf("request failed: %w", err)
9093
}
9194
if contentType := res.Header.Get("Content-Type"); !jsonMediaTypes.Matches(contentType) {

api/apitest/apitest.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,8 @@ func RunTUI(ctx context.Context, tui cli.TUI) (*bytes.Buffer, error) {
349349
}
350350
tty.w = io.MultiWriter(ptmx, &tty.buf)
351351

352-
output := termenv.NewOutput(tty)
352+
output := termenv.NewOutput(tty, termenv.WithProfile(termenv.Ascii))
353+
termenv.SetDefaultOutput(output)
353354
if err := tui.Run(ctx, output.TTY()); err != nil {
354355
return nil, err
355356
}

api/openapi.gen.go

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

auth/signin.go

Lines changed: 97 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
package auth
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"errors"
78
"fmt"
89
"net/http"
9-
"strings"
10+
"time"
1011

12+
"github.com/cli/browser"
13+
"github.com/mattn/go-isatty"
1114
"github.com/muesli/termenv"
12-
"golang.org/x/term"
1315

1416
"github.com/anchordotdev/cli"
1517
"github.com/anchordotdev/cli/api"
@@ -31,61 +33,129 @@ func (s SignIn) TUI() cli.TUI {
3133
}
3234

3335
func (s *SignIn) run(ctx context.Context, tty termenv.File) error {
34-
if len(s.Config.API.Token) == 0 {
35-
fmt.Fprintf(tty, "To complete sign in, please:\n 1. Visit https://anchor.dev/settings and add a new Personal Access Token (PAT).\n 2. Copy the key from the new token and paste it below when prompted.\n")
36+
output := termenv.DefaultOutput()
37+
cp := output.ColorProfile()
3638

37-
if _, err := fmt.Fprintf(tty, "Personal Access Token (PAT): "); err != nil {
38-
return err
39-
}
39+
anc, err := api.Client(s.Config)
40+
if err != nil && err != api.ErrSignedOut {
41+
return err
42+
}
4043

41-
line, err := term.ReadPassword(int(tty.Fd()))
42-
if err != nil {
43-
return err
44-
}
45-
pat := strings.TrimSpace(string(line))
46-
if !strings.HasPrefix(pat, "ap0_") || len(pat) != 64 {
47-
return fmt.Errorf("invalid PAT key")
48-
}
44+
codesRes, err := anc.Post("/auth/cli/codes", "application/json", nil)
45+
if err != nil {
46+
return err
47+
}
48+
if codesRes.StatusCode != http.StatusOK {
49+
return fmt.Errorf("unexpected response code: %d", codesRes.StatusCode)
50+
}
4951

50-
s.Config.API.Token = pat
52+
var codes *api.AuthCliCodesResponse
53+
if err = json.NewDecoder(codesRes.Body).Decode(&codes); err != nil {
54+
return err
55+
}
5156

52-
if _, err := fmt.Fprintln(tty); err != nil {
57+
fmt.Fprintln(tty)
58+
if isatty.IsTerminal(tty.Fd()) {
59+
fmt.Fprintln(tty,
60+
output.String("!").Foreground(cp.Color("#ff6000")),
61+
"First copy your user code:",
62+
output.String(codes.UserCode).Background(cp.Color("#7000ff")).Bold(),
63+
)
64+
fmt.Fprintln(tty,
65+
"Then",
66+
output.String("Press Enter").Bold(),
67+
"to open",
68+
output.String(codes.VerificationUri).Faint().Underline(),
69+
"in your browser...",
70+
)
71+
fmt.Scanln()
72+
73+
if err = browser.OpenURL(codes.VerificationUri); err != nil {
5374
return err
5475
}
76+
} else {
77+
fmt.Fprintln(tty,
78+
output.String("!").Foreground(cp.Color("#ff6000")),
79+
"Open",
80+
output.String(codes.VerificationUri).Faint().Underline(),
81+
"in a browser and enter your user code:",
82+
output.String(codes.UserCode).Bold(),
83+
)
5584
}
5685

57-
anc, err := api.Client(s.Config)
58-
if err != nil && err != api.ErrSignedOut {
86+
var looper = func() error {
87+
for {
88+
body := new(bytes.Buffer)
89+
req := api.CreateCliTokenJSONRequestBody{
90+
DeviceCode: codes.DeviceCode,
91+
}
92+
if err = json.NewEncoder(body).Encode(req); err != nil {
93+
return err
94+
}
95+
tokensRes, err := anc.Post("/auth/cli/pat_tokens", "application/json", body)
96+
if err != nil {
97+
return err
98+
}
99+
100+
switch tokensRes.StatusCode {
101+
case http.StatusOK:
102+
var patTokens *api.AuthCliPatTokensResponse
103+
if err = json.NewDecoder(tokensRes.Body).Decode(&patTokens); err != nil {
104+
return err
105+
}
106+
s.Config.API.Token = patTokens.PatToken
107+
return nil
108+
case http.StatusBadRequest:
109+
var errorsRes *api.Error
110+
if err = json.NewDecoder(tokensRes.Body).Decode(&errorsRes); err != nil {
111+
return err
112+
}
113+
switch errorsRes.Type {
114+
case "urn:anchordev:api:cli-auth:authorization-pending":
115+
time.Sleep(time.Duration(codes.Interval) * time.Second)
116+
case "urn:anchordev:api:cli-auth:incorrect-device-code":
117+
return fmt.Errorf("Your authorization request was not found, please try again.")
118+
default:
119+
return fmt.Errorf("unexpected error: %s", errorsRes.Detail)
120+
}
121+
default:
122+
return fmt.Errorf("unexpected response code: %d", tokensRes.StatusCode)
123+
}
124+
}
125+
}
126+
if err := looper(); err != nil {
59127
return err
60128
}
61129

62-
req, err := http.NewRequest("GET", "", nil)
130+
anc, err = api.Client(s.Config)
63131
if err != nil {
64132
return err
65133
}
66-
req.SetBasicAuth(s.Config.API.Token, "")
67134

68-
res, err := anc.Do(req)
135+
res, err := anc.Get("")
69136
if err != nil {
70137
return err
71138
}
72-
if res.StatusCode == http.StatusForbidden {
73-
return ErrSigninFailed
74-
}
75139
if res.StatusCode != http.StatusOK {
76140
return fmt.Errorf("unexpected response code: %d", res.StatusCode)
77141
}
78142

79143
var userInfo *api.Root
80144
if err := json.NewDecoder(res.Body).Decode(&userInfo); err != nil {
81-
return fmt.Errorf("decoding userInfo failed: %w", err)
145+
return err
82146
}
83147

84148
kr := keyring.Keyring{Config: s.Config}
85149
if err := kr.Set(keyring.APIToken, s.Config.API.Token); err != nil {
86150
return err
87151
}
88152

89-
fmt.Fprintf(tty, "Success, hello %s!\n", userInfo.Whoami)
153+
fmt.Fprintln(tty)
154+
fmt.Fprintf(tty,
155+
"Success, hello %s!",
156+
output.String(userInfo.Whoami).Bold(),
157+
)
158+
fmt.Fprintln(tty)
159+
90160
return nil
91161
}

auth/signin_test.go

Lines changed: 10 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,22 @@
11
package auth
22

33
import (
4-
"context"
54
"testing"
6-
7-
"github.com/anchordotdev/cli"
8-
"github.com/anchordotdev/cli/api/apitest"
95
)
106

117
func TestSignIn(t *testing.T) {
12-
ctx, cancel := context.WithCancel(context.Background())
13-
defer cancel()
14-
15-
cfg := new(cli.Config)
16-
cfg.API.URL = srv.URL
17-
cfg.Keyring.MockMode = true
18-
19-
t.Run("valid-token", func(t *testing.T) {
20-
var err error
21-
if cfg.API.Token, err = srv.GeneratePAT("example@example.com"); err != nil {
22-
t.Fatal(err)
23-
}
24-
25-
cmd := &SignIn{
26-
Config: cfg,
27-
}
28-
29-
buf, err := apitest.RunTUI(ctx, cmd.TUI())
30-
if err != nil {
31-
t.Fatal(err)
32-
}
33-
34-
if want, got := "Success, hello example@example.com!\n", buf.String(); want != got {
35-
t.Errorf("want output %q, got %q", want, got)
36-
}
8+
t.Run("cli-auth-success", func(t *testing.T) {
9+
t.Skip("cli auth test not yet implemented")
10+
return
3711
})
3812

39-
t.Run("invalid-token", func(t *testing.T) {
40-
if !srv.IsProxy() {
41-
t.Skip("server doesn't authenticate in mock mode")
42-
return
43-
}
44-
45-
cfg.API.Token = "bad-pat-token"
46-
47-
cmd := &SignIn{
48-
Config: cfg,
49-
}
13+
t.Run("valid-config-token", func(t *testing.T) {
14+
t.Skip("cli auth test not yet implemented")
15+
return
16+
})
5017

51-
_, err := apitest.RunTUI(ctx, cmd.TUI())
52-
if want, got := ErrSigninFailed, err; want != got {
53-
t.Fatalf("want signin failure error %q, got %q", want, got)
54-
}
18+
t.Run("invalid-config-token", func(t *testing.T) {
19+
t.Skip("cli auth test not yet implemented")
20+
return
5521
})
5622
}

auth/whoami.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,6 @@ func (w WhoAmI) TUI() cli.TUI {
2525

2626
func (w *WhoAmI) run(ctx context.Context, tty termenv.File) error {
2727
anc, err := api.Client(w.Config)
28-
if err == api.ErrSignedOut {
29-
fmt.Fprintf(tty, "Sign-in required!\n")
30-
return nil
31-
}
3228
if err != nil {
3329
return err
3430
}

auth/whoami_test.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"testing"
66

77
"github.com/anchordotdev/cli"
8+
"github.com/anchordotdev/cli/api"
89
"github.com/anchordotdev/cli/api/apitest"
910
)
1011

@@ -21,13 +22,9 @@ func TestWhoAmI(t *testing.T) {
2122
Config: cfg,
2223
}
2324

24-
buf, err := apitest.RunTUI(ctx, cmd.TUI())
25-
if err != nil {
26-
t.Fatal(err)
27-
}
28-
29-
if want, got := "Sign-in required!\n", buf.String(); want != got {
30-
t.Errorf("want output %q, got %q", want, got)
25+
_, err := apitest.RunTUI(ctx, cmd.TUI())
26+
if want, got := api.ErrSignedOut, err; want != got {
27+
t.Fatalf("want signin failure error %q, got %q", want, got)
3128
}
3229
})
3330

command.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ type Command struct {
3030
func (c *Command) Execute(ctx context.Context, cfg *Config) error {
3131
defaults.SetDefaults(cfg)
3232

33+
// enable ANSI processing for Windows, see: https://github.com/muesli/termenv#platform-support
34+
restoreConsole, err := termenv.EnableVirtualTerminalProcessing(termenv.DefaultOutput())
35+
if err != nil {
36+
panic(err)
37+
}
38+
defer restoreConsole()
39+
3340
cmd := c.cobraCommand(ctx, reflect.ValueOf(cfg))
3441

3542
if err := envdecode.Decode(cfg); err != nil && err != envdecode.ErrNoTargetFieldsAreSet {

0 commit comments

Comments
 (0)