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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ Breadcrumbs suggest next commands, making it easy for humans and agents to navig
OAuth 2.1 with automatic token refresh. First login opens your browser:

```bash
basecamp auth login # Read-only access (default)
basecamp auth login --scope full # Full read/write access
basecamp auth login # Authenticate with Basecamp
basecamp auth login --scope read # Read-only access (BC3 OAuth only, default)
basecamp auth login --scope full # Full read+write access (BC3 OAuth only)
basecamp auth token # Print token for scripts
```

Expand Down
4 changes: 2 additions & 2 deletions install.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Opens browser for OAuth. Grant access when prompted.
**Verify:**
```bash
basecamp auth status
# Expected: Authenticated (scope: read)
# Expected: Authenticated (BC3 OAuth may show "Authenticated (scope: read)")
```

---
Expand Down Expand Up @@ -137,7 +137,7 @@ cat ~/.config/basecamp/config.json
basecamp auth logout && basecamp auth login
```

**Permission denied (read-only):**
**Permission denied (read-only, BC3 OAuth only):**
```bash
basecamp auth login --scope full
```
56 changes: 43 additions & 13 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,13 @@ func (m *Manager) refreshLocked(ctx context.Context, origin string, creds *Crede
return m.store.Save(origin, creds)
}

// LoginResult holds the outcome of a successful Login().
// Callers use this to determine the effective scope instead of their input.
type LoginResult struct {
OAuthType string // "bc3" or "launchpad"
Scope string // effective scope: "read"/"full" for BC3, "" for Launchpad
}

// LoginOptions configures the login flow.
type LoginOptions struct {
Scope string
Expand Down Expand Up @@ -303,17 +310,22 @@ func resolveOAuthCallback(opts *LoginOptions) (redirectURI string, listenAddr st
}

// Login initiates the OAuth login flow.
func (m *Manager) Login(ctx context.Context, opts LoginOptions) error {
func (m *Manager) Login(ctx context.Context, opts LoginOptions) (*LoginResult, error) {
if opts.Remote && opts.Local {
return output.ErrUsage("--remote and --local are mutually exclusive")
return nil, output.ErrUsage("--remote and --local are mutually exclusive")
}

// Validate scope early (single source of truth)
if opts.Scope != "" && opts.Scope != "read" && opts.Scope != "full" {
return nil, output.ErrUsage("Invalid scope. Use 'read' or 'full'")
}

opts.defaults()

// Resolve redirect URI and listener address
redirectURI, listenAddr, err := resolveOAuthCallback(&opts)
if err != nil {
return err
return nil, err
}
opts.RedirectURI = redirectURI

Expand All @@ -327,13 +339,27 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error {
// Discover OAuth config
oauthCfg, oauthType, err := m.discoverOAuth(ctx, opts.log)
if err != nil {
return err
return nil, err
}

// Apply provider-aware scope rules
effectiveScope := opts.Scope
if oauthType == "launchpad" {
if effectiveScope != "" {
opts.log("Launchpad does not support OAuth scopes; --scope ignored")
}
effectiveScope = ""
} else {
// BC3: default to "read" when no scope specified
if effectiveScope == "" {
effectiveScope = "read"
}
}

// Load or register client credentials
clientCreds, err := m.loadClientCredentials(ctx, oauthCfg, oauthType, &opts)
if err != nil {
return err
return nil, err
}

// Generate PKCE challenge (for BC3)
Expand All @@ -347,9 +373,9 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error {
state := pkce.GenerateState()

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

var code string
Expand All @@ -374,14 +400,14 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error {

code, err = readCallbackInput(waitCtx, reader, state)
if err != nil {
return err
return nil, err
}
} else {
// Local mode: start listener and wait for callback
lc := net.ListenConfig{}
listener, listenErr := lc.Listen(ctx, "tcp", listenAddr)
if listenErr != nil {
return fmt.Errorf("failed to start callback server: %w", listenErr)
return nil, fmt.Errorf("failed to start callback server: %w", listenErr)
}
defer func() { _ = listener.Close() }()

Expand All @@ -403,21 +429,25 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error {

code, err = oauthcallback.WaitForCallback(waitCtx, state, listener, "")
if err != nil {
return err
return nil, err
}
}

// Exchange code for tokens
creds, err := m.exchangeCode(ctx, oauthCfg, oauthType, code, codeVerifier, clientCreds, &opts)
if err != nil {
return err
return nil, err
}

creds.OAuthType = oauthType
creds.TokenEndpoint = oauthCfg.TokenEndpoint
creds.Scope = opts.Scope
creds.Scope = effectiveScope

return m.store.Save(credKey, creds)
if err := m.store.Save(credKey, creds); err != nil {
return nil, err
}

return &LoginResult{OAuthType: oauthType, Scope: effectiveScope}, nil
}

// Logout removes stored credentials.
Expand Down
Loading
Loading