Skip to content

Commit ae6ff92

Browse files
authored
Merge pull request #355 from CircleCI-Public/hanabel/oauth
Add OAuth login flow for CircleCI authentication
2 parents 1057b1c + f718b3f commit ae6ff92

17 files changed

Lines changed: 794 additions & 17 deletions

internal/cmd/auth.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/CircleCI-Public/chunk-cli/internal/config"
1313
"github.com/CircleCI-Public/chunk-cli/internal/iostream"
1414
"github.com/CircleCI-Public/chunk-cli/internal/keyring"
15+
"github.com/CircleCI-Public/chunk-cli/internal/oauth"
1516
"github.com/CircleCI-Public/chunk-cli/internal/tui"
1617
"github.com/CircleCI-Public/chunk-cli/internal/ui"
1718
)
@@ -29,12 +30,53 @@ func newAuthCmd() *cobra.Command {
2930
RunE: groupRunE,
3031
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
3132
}
33+
cmd.AddCommand(newAuthLoginCmd())
3234
cmd.AddCommand(newAuthSetCmd())
3335
cmd.AddCommand(newAuthStatusCmd())
3436
cmd.AddCommand(newAuthRemoveCmd())
3537
return cmd
3638
}
3739

40+
func newAuthLoginCmd() *cobra.Command {
41+
var noBrowser bool
42+
var signup bool
43+
cmd := &cobra.Command{
44+
Use: "login",
45+
Short: "Log in to CircleCI via browser (recommended)",
46+
Long: "Authenticate with CircleCI using OAuth. Opens your browser for a secure login flow.",
47+
RunE: func(cmd *cobra.Command, _ []string) error {
48+
insecureStorage, _ := cmd.Flags().GetBool("insecure-storage")
49+
rc, _ := config.Resolve("", "", insecureStorage)
50+
io := iostream.FromCmd(cmd)
51+
return authLogin(cmd.Context(), io, rc.CircleCIBaseURL, noBrowser, signup, insecureStorage)
52+
},
53+
}
54+
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Print the login URL instead of opening a browser")
55+
cmd.Flags().BoolVar(&signup, "signup", false, "Route to the signup page instead of login")
56+
return cmd
57+
}
58+
59+
func authLogin(ctx context.Context, streams iostream.Streams, baseURL string, noBrowser, signup, insecureStorage bool) error {
60+
streams.Println("")
61+
streams.Println(ui.Bold("Chunk CLI - CircleCI Login"))
62+
streams.Println("")
63+
64+
token, err := oauth.Login(ctx, oauth.LoginConfig{
65+
BaseURL: baseURL,
66+
NoBrowser: noBrowser,
67+
Signup: signup,
68+
}, streams.Err)
69+
if err != nil {
70+
return &userError{
71+
msg: "Login failed.",
72+
suggestion: "Try again or use `chunk auth set circleci` to set a token manually.",
73+
err: fmt.Errorf("oauth login: %w", err),
74+
}
75+
}
76+
77+
return saveCircleCIToken(ctx, token, streams, baseURL, insecureStorage)
78+
}
79+
3880
func newAuthSetCmd() *cobra.Command {
3981
var force bool
4082
cmd := &cobra.Command{

internal/cmd/authhelper.go

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ import (
1616
"github.com/CircleCI-Public/chunk-cli/internal/github"
1717
hc "github.com/CircleCI-Public/chunk-cli/internal/httpcl"
1818
"github.com/CircleCI-Public/chunk-cli/internal/iostream"
19+
"github.com/CircleCI-Public/chunk-cli/internal/oauth"
1920
"github.com/CircleCI-Public/chunk-cli/internal/tui"
2021
"github.com/CircleCI-Public/chunk-cli/internal/ui"
2122
)
2223

2324
const (
24-
suggestionCircleCIAuth = "Set " + config.EnvCircleToken + " or run 'chunk auth set circleci'."
25+
suggestionCircleCIAuth = "Set " + config.EnvCircleToken + " or run 'chunk auth login'."
2526
suggestionAnthropicAuth = "Set " + config.EnvAnthropicAPIKey + " or run 'chunk auth set anthropic'."
2627
suggestionGitHubAuth = "Set " + config.EnvGitHubToken + " or run 'chunk auth set github'."
2728
)
@@ -64,30 +65,56 @@ func ensureCircleCIClient(ctx context.Context, cmd *cobra.Command, rc config.Res
6465
}
6566

6667
streams.ErrPrintln("")
67-
streams.ErrPrintln(ui.Bold("CircleCI token required"))
68-
streams.ErrPrintln("Create a token at https://app.circleci.com/settings/user/tokens")
69-
streams.ErrPrintln("Don't have an account? Sign up at https://app.circleci.com/signup")
68+
streams.ErrPrintln(ui.Bold("CircleCI authentication required"))
7069
printSaveHint(streams, "Token", insecureStorage)
7170
streams.ErrPrintln("")
7271

73-
token, err := prompter("CircleCI Token")
74-
if err != nil {
75-
if errors.Is(err, tui.ErrNoTTY) {
72+
choice, selectErr := tui.SelectFromList("How would you like to authenticate?", []string{
73+
"Log in via browser (recommended)",
74+
"Enter a token manually",
75+
})
76+
if selectErr != nil {
77+
if errors.Is(selectErr, tui.ErrNoTTY) {
7678
return nil, newUserError("CircleCI token required.").
7779
withCode("auth.circleci_token_required").
7880
withSuggestion(suggestionCircleCIAuth).
7981
withExitCode(ExitAuthError).
80-
wrap(err)
82+
wrap(selectErr)
8183
}
82-
return nil, err
84+
return nil, selectErr
8385
}
84-
token = strings.TrimSpace(token)
85-
if token == "" {
86-
return nil, newUserError("CircleCI token required.").
87-
withCode("auth.circleci_token_required").
88-
withSuggestion(suggestionCircleCIAuth).
89-
withExitCode(ExitAuthError).
90-
wrapMsg("empty token entered")
86+
87+
var token string
88+
switch choice {
89+
case 0:
90+
token, err = oauth.Login(ctx, oauth.LoginConfig{
91+
BaseURL: rc.CircleCIBaseURL,
92+
}, streams.Err)
93+
if err != nil {
94+
return nil, fmt.Errorf("oauth login: %w", err)
95+
}
96+
case 1:
97+
streams.ErrPrintln("Create a token at https://app.circleci.com/settings/user/tokens")
98+
streams.ErrPrintln("")
99+
token, err = prompter("CircleCI Token")
100+
if err != nil {
101+
if errors.Is(err, tui.ErrNoTTY) {
102+
return nil, newUserError("CircleCI token required.").
103+
withCode("auth.circleci_token_required").
104+
withSuggestion(suggestionCircleCIAuth).
105+
withExitCode(ExitAuthError).
106+
wrap(err)
107+
}
108+
return nil, err
109+
}
110+
token = strings.TrimSpace(token)
111+
if token == "" {
112+
return nil, newUserError("CircleCI token required.").
113+
withCode("auth.circleci_token_required").
114+
withSuggestion(suggestionCircleCIAuth).
115+
withExitCode(ExitAuthError).
116+
wrapMsg("empty token entered")
117+
}
91118
}
92119

93120
streams.ErrPrintln(ui.Dim("Validating CircleCI token..."))

internal/cmd/validate_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func TestValidateHookExitsOneWhenCircleCITokenMissingAndRemoteCommands(t *testin
5959
assert.Equal(t, ec.ExitCode(), 1)
6060
assert.Assert(t, strings.Contains(stderr, "CircleCI auth is not configured"),
6161
"expected auth message in stderr, got: %q", stderr)
62-
assert.Assert(t, strings.Contains(stderr, "chunk auth set circleci"),
62+
assert.Assert(t, strings.Contains(stderr, "chunk auth login"),
6363
"expected auth hint in stderr, got: %q", stderr)
6464
}
6565

internal/oauth/browser.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package oauth
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"runtime"
7+
)
8+
9+
func OpenBrowser(url string) error {
10+
switch runtime.GOOS {
11+
case "darwin":
12+
return exec.Command("open", url).Start()
13+
case "linux":
14+
return exec.Command("xdg-open", url).Start()
15+
case "windows":
16+
return exec.Command("cmd", "/c", "start", url).Start()
17+
default:
18+
return fmt.Errorf("unsupported platform %s", runtime.GOOS)
19+
}
20+
}

internal/oauth/callback.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package oauth
2+
3+
import (
4+
"context"
5+
"net"
6+
"net/http"
7+
"time"
8+
)
9+
10+
type CallbackResult struct {
11+
Code string
12+
State string
13+
Error string
14+
}
15+
16+
func writeBrowserResponse(w http.ResponseWriter, status int, state browserState) {
17+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
18+
w.WriteHeader(status)
19+
renderPage(w, state)
20+
}
21+
22+
func ListenForCallback(ctx context.Context) (port int, result <-chan CallbackResult, cleanup func(), err error) {
23+
listener, err := net.Listen("tcp", "127.0.0.1:0")
24+
if err != nil {
25+
return 0, nil, nil, err
26+
}
27+
port = listener.Addr().(*net.TCPAddr).Port
28+
29+
ch := make(chan CallbackResult, 1)
30+
mux := http.NewServeMux()
31+
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
32+
q := r.URL.Query()
33+
res := CallbackResult{
34+
Code: q.Get("code"),
35+
State: q.Get("state"),
36+
Error: q.Get("error"),
37+
}
38+
39+
if res.Error != "" {
40+
writeBrowserResponse(w, http.StatusBadRequest, browserState{
41+
Title: "Authorization failed",
42+
Body: "Login was denied. You can close this tab.",
43+
})
44+
} else {
45+
writeBrowserResponse(w, http.StatusOK, browserState{
46+
Title: "Authorization successful",
47+
Body: "You can close this window and return to your terminal.",
48+
Success: true,
49+
})
50+
}
51+
52+
select {
53+
case ch <- res:
54+
default:
55+
}
56+
})
57+
58+
srv := &http.Server{
59+
Handler: mux,
60+
ReadHeaderTimeout: 10 * time.Second,
61+
BaseContext: func(_ net.Listener) context.Context {
62+
return ctx
63+
},
64+
}
65+
66+
go func() {
67+
_ = srv.Serve(listener)
68+
}()
69+
70+
cleanup = func() {
71+
_ = srv.Shutdown(context.Background())
72+
}
73+
74+
return port, ch, cleanup, nil
75+
}

internal/oauth/callback_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package oauth
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"testing"
8+
"time"
9+
10+
"gotest.tools/v3/assert"
11+
)
12+
13+
func TestListenForCallback_Success(t *testing.T) {
14+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
15+
defer cancel()
16+
17+
port, resultCh, cleanup, err := ListenForCallback(ctx)
18+
assert.NilError(t, err)
19+
defer cleanup()
20+
21+
url := fmt.Sprintf("http://127.0.0.1:%d/callback?code=test-code&state=test-state", port)
22+
resp, err := http.Get(url)
23+
assert.NilError(t, err)
24+
resp.Body.Close()
25+
assert.Equal(t, resp.StatusCode, http.StatusOK)
26+
27+
res := <-resultCh
28+
assert.Equal(t, res.Code, "test-code")
29+
assert.Equal(t, res.State, "test-state")
30+
assert.Equal(t, res.Error, "")
31+
}
32+
33+
func TestListenForCallback_Error(t *testing.T) {
34+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
35+
defer cancel()
36+
37+
port, resultCh, cleanup, err := ListenForCallback(ctx)
38+
assert.NilError(t, err)
39+
defer cleanup()
40+
41+
url := fmt.Sprintf("http://127.0.0.1:%d/callback?error=access_denied&state=test-state", port)
42+
resp, err := http.Get(url)
43+
assert.NilError(t, err)
44+
resp.Body.Close()
45+
46+
res := <-resultCh
47+
assert.Equal(t, res.Error, "access_denied")
48+
assert.Equal(t, res.State, "test-state")
49+
assert.Equal(t, res.Code, "")
50+
}
51+
52+
func TestListenForCallback_ContextCancel(t *testing.T) {
53+
ctx, cancel := context.WithCancel(context.Background())
54+
55+
_, _, cleanup, err := ListenForCallback(ctx)
56+
assert.NilError(t, err)
57+
defer cleanup()
58+
59+
cancel()
60+
// Server should shut down without hanging; cleanup is the verification.
61+
}

internal/oauth/deviceid.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package oauth
2+
3+
import (
4+
"crypto/rand"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/CircleCI-Public/chunk-cli/internal/config"
11+
)
12+
13+
const deviceIDFile = "device_id"
14+
15+
func LoadOrCreateDeviceID() (string, error) {
16+
dir, err := config.AppState()
17+
if err != nil {
18+
return "", fmt.Errorf("resolve state dir: %w", err)
19+
}
20+
path := filepath.Join(dir, deviceIDFile)
21+
22+
data, err := os.ReadFile(path)
23+
if err == nil {
24+
id := strings.TrimSpace(string(data))
25+
if id != "" {
26+
return id, nil
27+
}
28+
}
29+
30+
id, err := generateUUID4()
31+
if err != nil {
32+
return "", fmt.Errorf("generate device id: %w", err)
33+
}
34+
35+
if err := os.MkdirAll(dir, 0o700); err != nil {
36+
return "", fmt.Errorf("create state dir: %w", err)
37+
}
38+
if err := os.WriteFile(path, []byte(id+"\n"), 0o600); err != nil {
39+
return "", fmt.Errorf("write device id: %w", err)
40+
}
41+
return id, nil
42+
}
43+
44+
func generateUUID4() (string, error) {
45+
var buf [16]byte
46+
if _, err := rand.Read(buf[:]); err != nil {
47+
return "", err
48+
}
49+
buf[6] = (buf[6] & 0x0f) | 0x40 // version 4
50+
buf[8] = (buf[8] & 0x3f) | 0x80 // variant 10
51+
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
52+
buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]), nil
53+
}

0 commit comments

Comments
 (0)