Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/CircleCI-Public/chunk-cli/internal/config"
"github.com/CircleCI-Public/chunk-cli/internal/iostream"
"github.com/CircleCI-Public/chunk-cli/internal/keyring"
"github.com/CircleCI-Public/chunk-cli/internal/oauth"
"github.com/CircleCI-Public/chunk-cli/internal/tui"
"github.com/CircleCI-Public/chunk-cli/internal/ui"
)
Expand All @@ -29,12 +30,53 @@ func newAuthCmd() *cobra.Command {
RunE: groupRunE,
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
}
cmd.AddCommand(newAuthLoginCmd())
cmd.AddCommand(newAuthSetCmd())
cmd.AddCommand(newAuthStatusCmd())
cmd.AddCommand(newAuthRemoveCmd())
return cmd
}

func newAuthLoginCmd() *cobra.Command {
var noBrowser bool
var signup bool
cmd := &cobra.Command{
Use: "login",
Short: "Log in to CircleCI via browser (recommended)",
Long: "Authenticate with CircleCI using OAuth. Opens your browser for a secure login flow.",
RunE: func(cmd *cobra.Command, _ []string) error {
insecureStorage, _ := cmd.Flags().GetBool("insecure-storage")
rc, _ := config.Resolve("", "", insecureStorage)
io := iostream.FromCmd(cmd)
return authLogin(cmd.Context(), io, rc.CircleCIBaseURL, noBrowser, signup, insecureStorage)
},
}
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Print the login URL instead of opening a browser")
cmd.Flags().BoolVar(&signup, "signup", false, "Route to the signup page instead of login")
return cmd
}

func authLogin(ctx context.Context, streams iostream.Streams, baseURL string, noBrowser, signup, insecureStorage bool) error {
streams.Println("")
streams.Println(ui.Bold("Chunk CLI - CircleCI Login"))
streams.Println("")

token, err := oauth.Login(ctx, oauth.LoginConfig{
BaseURL: baseURL,
NoBrowser: noBrowser,
Signup: signup,
}, streams.Err)
if err != nil {
return &userError{
msg: "Login failed.",
suggestion: "Try again or use `chunk auth set circleci` to set a token manually.",
err: fmt.Errorf("oauth login: %w", err),
}
}

return saveCircleCIToken(ctx, token, streams, baseURL, insecureStorage)
}

func newAuthSetCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Expand Down
59 changes: 43 additions & 16 deletions internal/cmd/authhelper.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ import (
"github.com/CircleCI-Public/chunk-cli/internal/github"
hc "github.com/CircleCI-Public/chunk-cli/internal/httpcl"
"github.com/CircleCI-Public/chunk-cli/internal/iostream"
"github.com/CircleCI-Public/chunk-cli/internal/oauth"
"github.com/CircleCI-Public/chunk-cli/internal/tui"
"github.com/CircleCI-Public/chunk-cli/internal/ui"
)

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

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

token, err := prompter("CircleCI Token")
if err != nil {
if errors.Is(err, tui.ErrNoTTY) {
choice, selectErr := tui.SelectFromList("How would you like to authenticate?", []string{
"Log in via browser (recommended)",
"Enter a token manually",
})
if selectErr != nil {
if errors.Is(selectErr, tui.ErrNoTTY) {
return nil, newUserError("CircleCI token required.").
withCode("auth.circleci_token_required").
withSuggestion(suggestionCircleCIAuth).
withExitCode(ExitAuthError).
wrap(err)
wrap(selectErr)
}
return nil, err
return nil, selectErr
}
token = strings.TrimSpace(token)
if token == "" {
return nil, newUserError("CircleCI token required.").
withCode("auth.circleci_token_required").
withSuggestion(suggestionCircleCIAuth).
withExitCode(ExitAuthError).
wrapMsg("empty token entered")

var token string
switch choice {
case 0:
token, err = oauth.Login(ctx, oauth.LoginConfig{
BaseURL: rc.CircleCIBaseURL,
}, streams.Err)
if err != nil {
return nil, fmt.Errorf("oauth login: %w", err)
}
case 1:
streams.ErrPrintln("Create a token at https://app.circleci.com/settings/user/tokens")
streams.ErrPrintln("")
token, err = prompter("CircleCI Token")
if err != nil {
if errors.Is(err, tui.ErrNoTTY) {
return nil, newUserError("CircleCI token required.").
withCode("auth.circleci_token_required").
withSuggestion(suggestionCircleCIAuth).
withExitCode(ExitAuthError).
wrap(err)
}
return nil, err
}
token = strings.TrimSpace(token)
if token == "" {
return nil, newUserError("CircleCI token required.").
withCode("auth.circleci_token_required").
withSuggestion(suggestionCircleCIAuth).
withExitCode(ExitAuthError).
wrapMsg("empty token entered")
}
}

streams.ErrPrintln(ui.Dim("Validating CircleCI token..."))
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func TestValidateHookExitsOneWhenCircleCITokenMissingAndRemoteCommands(t *testin
assert.Equal(t, ec.ExitCode(), 1)
assert.Assert(t, strings.Contains(stderr, "CircleCI auth is not configured"),
"expected auth message in stderr, got: %q", stderr)
assert.Assert(t, strings.Contains(stderr, "chunk auth set circleci"),
assert.Assert(t, strings.Contains(stderr, "chunk auth login"),
"expected auth hint in stderr, got: %q", stderr)
}

Expand Down
20 changes: 20 additions & 0 deletions internal/oauth/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package oauth

import (
"fmt"
"os/exec"
"runtime"
)

func OpenBrowser(url string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url).Start()
case "linux":
return exec.Command("xdg-open", url).Start()
case "windows":
return exec.Command("cmd", "/c", "start", url).Start()
default:
return fmt.Errorf("unsupported platform %s", runtime.GOOS)
}
}
63 changes: 63 additions & 0 deletions internal/oauth/callback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package oauth

import (
"context"
"net"
"net/http"
"time"
)

type CallbackResult struct {
Code string
State string
Error string
}

func ListenForCallback(ctx context.Context) (port int, result <-chan CallbackResult, cleanup func(), err error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, nil, nil, err
}
port = listener.Addr().(*net.TCPAddr).Port

ch := make(chan CallbackResult, 1)
mux := http.NewServeMux()
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
res := CallbackResult{
Code: q.Get("code"),
State: q.Get("state"),
Error: q.Get("error"),
}

w.Header().Set("Content-Type", "text/html; charset=utf-8")
if res.Error != "" {
_, _ = w.Write([]byte("<html><body><h2>Login was denied.</h2><p>You can close this tab.</p></body></html>"))
} else {
_, _ = w.Write([]byte("<html><body><h2>Login successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>"))
}

select {
case ch <- res:
default:
}
})

srv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
BaseContext: func(_ net.Listener) context.Context {
return ctx
},
}

go func() {
_ = srv.Serve(listener)
}()

cleanup = func() {
_ = srv.Shutdown(context.Background())
}

return port, ch, cleanup, nil
}
61 changes: 61 additions & 0 deletions internal/oauth/callback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package oauth

import (
"context"
"fmt"
"net/http"
"testing"
"time"

"gotest.tools/v3/assert"
)

func TestListenForCallback_Success(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

port, resultCh, cleanup, err := ListenForCallback(ctx)
assert.NilError(t, err)
defer cleanup()

url := fmt.Sprintf("http://127.0.0.1:%d/callback?code=test-code&state=test-state", port)
resp, err := http.Get(url)
assert.NilError(t, err)
resp.Body.Close()
assert.Equal(t, resp.StatusCode, http.StatusOK)

res := <-resultCh
assert.Equal(t, res.Code, "test-code")
assert.Equal(t, res.State, "test-state")
assert.Equal(t, res.Error, "")
}

func TestListenForCallback_Error(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

port, resultCh, cleanup, err := ListenForCallback(ctx)
assert.NilError(t, err)
defer cleanup()

url := fmt.Sprintf("http://127.0.0.1:%d/callback?error=access_denied&state=test-state", port)
resp, err := http.Get(url)
assert.NilError(t, err)
resp.Body.Close()

res := <-resultCh
assert.Equal(t, res.Error, "access_denied")
assert.Equal(t, res.State, "test-state")
assert.Equal(t, res.Code, "")
}

func TestListenForCallback_ContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())

_, _, cleanup, err := ListenForCallback(ctx)
assert.NilError(t, err)
defer cleanup()

cancel()
// Server should shut down without hanging; cleanup is the verification.
}
53 changes: 53 additions & 0 deletions internal/oauth/deviceid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package oauth

import (
"crypto/rand"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/CircleCI-Public/chunk-cli/internal/config"
)

const deviceIDFile = "device_id"

func LoadOrCreateDeviceID() (string, error) {
dir, err := config.AppState()
if err != nil {
return "", fmt.Errorf("resolve state dir: %w", err)
}
path := filepath.Join(dir, deviceIDFile)

data, err := os.ReadFile(path)
if err == nil {
id := strings.TrimSpace(string(data))
if id != "" {
return id, nil
}
}

id, err := generateUUID4()
if err != nil {
return "", fmt.Errorf("generate device id: %w", err)
}

if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create state dir: %w", err)
}
if err := os.WriteFile(path, []byte(id+"\n"), 0o600); err != nil {
return "", fmt.Errorf("write device id: %w", err)
}
return id, nil
}

func generateUUID4() (string, error) {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
buf[6] = (buf[6] & 0x0f) | 0x40 // version 4
buf[8] = (buf[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]), nil
}
32 changes: 32 additions & 0 deletions internal/oauth/deviceid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package oauth

import (
"regexp"
"testing"

"gotest.tools/v3/assert"

"github.com/CircleCI-Public/chunk-cli/internal/config"
)

var uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)

func TestLoadOrCreateDeviceID_Creates(t *testing.T) {
t.Setenv(config.EnvXDGStateHome, t.TempDir())

id, err := LoadOrCreateDeviceID()
assert.NilError(t, err)
assert.Assert(t, uuidPattern.MatchString(id), "expected UUID v4, got %q", id)
}

func TestLoadOrCreateDeviceID_Reuses(t *testing.T) {
t.Setenv(config.EnvXDGStateHome, t.TempDir())

id1, err := LoadOrCreateDeviceID()
assert.NilError(t, err)

id2, err := LoadOrCreateDeviceID()
assert.NilError(t, err)

assert.Equal(t, id1, id2)
}
Loading