Skip to content
Merged
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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.26

require (
github.com/basecamp/basecamp-sdk/go v0.2.2
github.com/basecamp/cli v0.1.0
github.com/charmbracelet/bubbles v1.0.0
Comment thread
jeremy marked this conversation as resolved.
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v0.10.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/basecamp-sdk/go v0.2.2 h1:wfMrjTytLCLsBG2SrQh5UDvGgj3QHVwg6KRvkL+ayeg=
github.com/basecamp/basecamp-sdk/go v0.2.2/go.mod h1:WmckHy36EAqP+BW//1J9QdMi16l3PNx2XP0vt/kSlXE=
github.com/basecamp/cli v0.1.0 h1:0fA06OgHD0oObY3aCC8E6QS2jNxCmwYfUeUwK/zyNQw=
github.com/basecamp/cli v0.1.0/go.mod h1:NTHe+keCTGI2qM5sMXdkUN0QgU3zGbwnBxcmg8vD5QU=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
Expand Down
142 changes: 30 additions & 112 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ package auth

import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
Expand All @@ -18,6 +15,8 @@ import (
"time"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp/oauth"
"github.com/basecamp/cli/oauthcallback"
"github.com/basecamp/cli/pkce"

"github.com/basecamp/basecamp-cli/internal/config"
"github.com/basecamp/basecamp-cli/internal/hostutil"
Expand Down Expand Up @@ -316,21 +315,44 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error {
// Generate PKCE challenge (for BC3)
var codeVerifier, codeChallenge string
if oauthType == "bc3" {
codeVerifier = generateCodeVerifier()
codeChallenge = generateCodeChallenge(codeVerifier)
codeVerifier = pkce.GenerateVerifier()
codeChallenge = pkce.GenerateChallenge(codeVerifier)
}

// Generate state for CSRF protection
state := generateState()
state := pkce.GenerateState()

// Build authorization URL
authURL, err := m.buildAuthURL(oauthCfg, oauthType, opts.Scope, state, codeChallenge, clientCreds.ClientID, &opts)
if err != nil {
return err
}

// Start local callback server
code, err := m.waitForCallback(ctx, state, authURL, listenAddr, &opts)
// Start listener for OAuth callback
lc := net.ListenConfig{}
listener, err := lc.Listen(ctx, "tcp", listenAddr)
if err != nil {
return fmt.Errorf("failed to start callback server: %w", err)
}
defer func() { _ = listener.Close() }()

// Open browser for authentication
if opts.BrowserLauncher != nil {
if err := opts.BrowserLauncher(authURL); err != nil {
opts.log("\nCouldn't open browser automatically.\nOpen this URL in your browser:\n" + authURL + "\n\nWaiting for authentication...")
} else {
opts.log("\nOpening browser for authentication...")
opts.log("If the browser doesn't open, visit: " + authURL + "\n\nWaiting for authentication...")
}
} else {
opts.log("\nOpen this URL in your browser:\n" + authURL + "\n\nWaiting for authentication...")
}

// Wait for OAuth callback with a hard timeout to avoid hanging indefinitely
waitCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()

code, err := oauthcallback.WaitForCallback(waitCtx, state, listener, "")
if err != nil {
return err
}
Expand Down Expand Up @@ -561,87 +583,6 @@ func (m *Manager) buildAuthURL(cfg *oauth.Config, oauthType, scope, state, codeC
return u.String(), nil
}

func (m *Manager) waitForCallback(ctx context.Context, expectedState, authURL, listenAddr string, opts *LoginOptions) (string, error) {
// Start listener
lc := net.ListenConfig{}
listener, err := lc.Listen(ctx, "tcp", listenAddr)
if err != nil {
return "", fmt.Errorf("failed to start callback server: %w", err)
}
defer func() { _ = listener.Close() }()

codeCh := make(chan string, 1)
errCh := make(chan error, 1)
var shutdownOnce sync.Once

server := &http.Server{
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
}

server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
errParam := r.URL.Query().Get("error")

if errParam != "" {
errCh <- fmt.Errorf("OAuth error: %s", errParam)
fmt.Fprint(w, "<html><body><h1>Authentication failed</h1><p>You can close this window.</p></body></html>")
shutdownOnce.Do(func() { //nolint:contextcheck // decoupled from outer ctx intentionally
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
go func() { defer cancel(); server.Shutdown(ctx) }() //nolint:errcheck // best-effort shutdown
})
return
}

if state != expectedState {
errCh <- fmt.Errorf("state mismatch: CSRF protection failed")
fmt.Fprint(w, "<html><body><h1>Authentication failed</h1><p>State mismatch.</p></body></html>")
shutdownOnce.Do(func() { //nolint:contextcheck // decoupled from outer ctx intentionally
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
go func() { defer cancel(); server.Shutdown(ctx) }() //nolint:errcheck // best-effort shutdown
})
return
}

codeCh <- code
fmt.Fprint(w, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
shutdownOnce.Do(func() { //nolint:contextcheck // decoupled from outer ctx intentionally
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
go func() { defer cancel(); server.Shutdown(ctx) }() //nolint:errcheck // best-effort shutdown
})
})

go server.Serve(listener) //nolint:errcheck // server.Serve returns ErrServerClosed on Shutdown

// Try to open browser automatically unless --no-browser was specified
if opts.BrowserLauncher != nil {
if err := opts.BrowserLauncher(authURL); err != nil {
// Fall back to printing URL if browser open fails
opts.log("\nCouldn't open browser automatically.\nOpen this URL in your browser:\n" + authURL + "\n\nWaiting for authentication...")
} else {
opts.log("\nOpening browser for authentication...")
opts.log("If the browser doesn't open, visit: " + authURL + "\n\nWaiting for authentication...")
}
} else {
opts.log("\nOpen this URL in your browser:\n" + authURL + "\n\nWaiting for authentication...")
}

// Wait for callback or timeout
select {
case code := <-codeCh:
return code, nil
case err := <-errCh:
return "", err
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(5 * time.Minute):
return "", fmt.Errorf("authentication timeout waiting for callback on %s", listenAddr)
}
}

func (m *Manager) exchangeCode(ctx context.Context, cfg *oauth.Config, oauthType, code, codeVerifier string, clientCreds *ClientCredentials, opts *LoginOptions) (*Credentials, error) {
exchanger := oauth.NewExchanger(m.httpClient)

Expand Down Expand Up @@ -670,29 +611,6 @@ func (m *Manager) exchangeCode(ctx context.Context, cfg *oauth.Config, oauthType
return creds, nil
}

// PKCE helpers

func generateCodeVerifier() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}

func generateCodeChallenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}

func generateState() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}

// openBrowser opens the specified URL in the default browser.
func openBrowser(url string) error {
return hostutil.OpenBrowser(url)
Expand Down
Loading
Loading