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
3 changes: 2 additions & 1 deletion internal/appctx/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,8 @@ func FromContext(ctx context.Context) *App {
// ForAccount() will panic if accountID is empty or non-numeric.
//
// For account-agnostic operations (like Authorization().GetInfo()),
// use app.SDK directly.
// use app.SDK directly. Resolve the authorization endpoint via
// app.Auth.AuthorizationEndpoint() rather than passing nil options.
func (a *App) Account() *basecamp.AccountClient {
return a.SDK.ForAccount(a.Config.AccountID)
}
Expand Down
38 changes: 38 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,44 @@ func openBrowser(url string) error {
return hostutil.OpenBrowser(url)
}

// bc3TokenPrefix is the prefix for tokens issued by Basecamp 3's OAuth server.
const bc3TokenPrefix = "bc_at_"

// AuthorizationEndpoint returns the authorization info endpoint URL for the
// current authentication context. BASECAMP_TOKEN takes precedence over stored
// credentials (mirroring AccessToken), with the token prefix used to determine
// the issuer. When no env token is set, stored OAuth type drives selection.
func (m *Manager) AuthorizationEndpoint(ctx context.Context) (string, error) {
// BASECAMP_TOKEN wins — match AccessToken() precedence (auth.go line 75).
if envToken := os.Getenv("BASECAMP_TOKEN"); envToken != "" {
if strings.HasPrefix(envToken, bc3TokenPrefix) {
return config.NormalizeBaseURL(m.cfg.BaseURL) + "/authorization.json", nil
}
lpURL, err := m.launchpadURL()
if err != nil {
return "", err
}
return strings.TrimSuffix(lpURL, "/") + "/authorization.json", nil
}

oauthType := m.GetOAuthType()
switch oauthType {
case "bc3":
return config.NormalizeBaseURL(m.cfg.BaseURL) + "/authorization.json", nil
case "launchpad", "":
// "launchpad" = stored credentials; "" = no stored credentials and
// no env token (shouldn't normally reach here since IsAuthenticated
// would have caught it, but handle gracefully).
lpURL, err := m.launchpadURL()
if err != nil {
return "", err
}
return strings.TrimSuffix(lpURL, "/") + "/authorization.json", nil
default:
return "", output.ErrAuth("Unknown OAuth type: " + oauthType)
}
}

// GetOAuthType returns the OAuth type for the current credential key ("bc3" or "launchpad").
func (m *Manager) GetOAuthType() string {
credKey := m.credentialKey()
Expand Down
162 changes: 162 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1557,3 +1557,165 @@ func TestLoginRejectsInvalidScope(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "Invalid scope")
}

// --- AuthorizationEndpoint tests ---

func TestAuthorizationEndpoint_StoredBC3(t *testing.T) {
t.Setenv("BASECAMP_TOKEN", "")

tmpDir := t.TempDir()
cfg := &config.Config{BaseURL: "https://3.basecampapi.com"}
m := NewManager(cfg, nil)
m.store = newTestStore(t, tmpDir)

// Store bc3-type credentials
origin := config.NormalizeBaseURL(cfg.BaseURL)
require.NoError(t, m.store.Save(origin, &Credentials{
AccessToken: "tok",
OAuthType: "bc3",
}))

ep, err := m.AuthorizationEndpoint(context.Background())
require.NoError(t, err)
assert.Equal(t, "https://3.basecampapi.com/authorization.json", ep)
}

func TestAuthorizationEndpoint_StoredLaunchpad(t *testing.T) {
t.Setenv("BASECAMP_TOKEN", "")

tmpDir := t.TempDir()
cfg := &config.Config{BaseURL: "https://3.basecampapi.com"}
m := NewManager(cfg, nil)
m.store = newTestStore(t, tmpDir)

origin := config.NormalizeBaseURL(cfg.BaseURL)
require.NoError(t, m.store.Save(origin, &Credentials{
AccessToken: "tok",
OAuthType: "launchpad",
}))

ep, err := m.AuthorizationEndpoint(context.Background())
require.NoError(t, err)
assert.Equal(t, "https://launchpad.37signals.com/authorization.json", ep)
}

func TestAuthorizationEndpoint_StoredLaunchpadOverrideURL(t *testing.T) {
t.Setenv("BASECAMP_TOKEN", "")
t.Setenv("BASECAMP_LAUNCHPAD_URL", "https://custom-lp.example.com")

tmpDir := t.TempDir()
cfg := &config.Config{BaseURL: "https://3.basecampapi.com"}
m := NewManager(cfg, nil)
m.store = newTestStore(t, tmpDir)

origin := config.NormalizeBaseURL(cfg.BaseURL)
require.NoError(t, m.store.Save(origin, &Credentials{
AccessToken: "tok",
OAuthType: "launchpad",
}))

ep, err := m.AuthorizationEndpoint(context.Background())
require.NoError(t, err)
assert.Equal(t, "https://custom-lp.example.com/authorization.json", ep)
}

func TestAuthorizationEndpoint_TokenWithBC3Prefix(t *testing.T) {
t.Setenv("BASECAMP_TOKEN", "bc_at_abc123")

// Isolate from real credential store
tmpDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmpDir)

cfg := &config.Config{BaseURL: "https://3.basecampapi.com"}
m := NewManager(cfg, nil)
m.store = newTestStore(t, tmpDir)

ep, err := m.AuthorizationEndpoint(context.Background())
require.NoError(t, err)
assert.Equal(t, "https://3.basecampapi.com/authorization.json", ep)
}

func TestAuthorizationEndpoint_TokenWithoutBC3Prefix(t *testing.T) {
t.Setenv("BASECAMP_TOKEN", "some-launchpad-token")

// Isolate from real credential store
tmpDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmpDir)

cfg := &config.Config{BaseURL: "https://3.basecampapi.com"}
m := NewManager(cfg, nil)
m.store = newTestStore(t, tmpDir)

ep, err := m.AuthorizationEndpoint(context.Background())
require.NoError(t, err)
assert.Equal(t, "https://launchpad.37signals.com/authorization.json", ep)
}

func TestAuthorizationEndpoint_UnknownStoredType(t *testing.T) {
t.Setenv("BASECAMP_TOKEN", "")

tmpDir := t.TempDir()
cfg := &config.Config{BaseURL: "https://3.basecampapi.com"}
m := NewManager(cfg, nil)
m.store = newTestStore(t, tmpDir)

origin := config.NormalizeBaseURL(cfg.BaseURL)
require.NoError(t, m.store.Save(origin, &Credentials{
AccessToken: "tok",
OAuthType: "unknown",
}))

_, err := m.AuthorizationEndpoint(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "Unknown OAuth type")
}

// Regression: BASECAMP_TOKEN must override conflicting stored credentials.
// A user may export BASECAMP_TOKEN=bc_at_... while stale launchpad creds
// remain on disk. The endpoint must follow the token, not the stored type.

func TestAuthorizationEndpoint_BC3TokenOverridesStoredLaunchpad(t *testing.T) {
t.Setenv("BASECAMP_TOKEN", "bc_at_override_test")

tmpDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmpDir)

cfg := &config.Config{BaseURL: "https://3.basecampapi.com"}
m := NewManager(cfg, nil)
m.store = newTestStore(t, tmpDir)

// Stale stored credentials say "launchpad"
origin := config.NormalizeBaseURL(cfg.BaseURL)
require.NoError(t, m.store.Save(origin, &Credentials{
AccessToken: "stale-lp-token",
OAuthType: "launchpad",
}))

ep, err := m.AuthorizationEndpoint(context.Background())
require.NoError(t, err)
assert.Equal(t, "https://3.basecampapi.com/authorization.json", ep,
"bc_at_ env token must route to BC3, not stored launchpad")
}

func TestAuthorizationEndpoint_LaunchpadTokenOverridesStoredBC3(t *testing.T) {
t.Setenv("BASECAMP_TOKEN", "plain-launchpad-token")

tmpDir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmpDir)

cfg := &config.Config{BaseURL: "https://3.basecampapi.com"}
m := NewManager(cfg, nil)
m.store = newTestStore(t, tmpDir)

// Stale stored credentials say "bc3"
origin := config.NormalizeBaseURL(cfg.BaseURL)
require.NoError(t, m.store.Save(origin, &Credentials{
AccessToken: "stale-bc3-token",
OAuthType: "bc3",
}))

ep, err := m.AuthorizationEndpoint(context.Background())
require.NoError(t, err)
assert.Equal(t, "https://launchpad.37signals.com/authorization.json", ep,
"non-bc_at_ env token must route to launchpad, not stored bc3")
}
14 changes: 13 additions & 1 deletion internal/commands/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"github.com/spf13/cobra"
"github.com/zalando/go-keyring"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"

"github.com/basecamp/basecamp-cli/internal/appctx"
"github.com/basecamp/basecamp-cli/internal/config"
"github.com/basecamp/basecamp-cli/internal/harness"
Expand Down Expand Up @@ -664,8 +666,18 @@ func checkAPIConnectivity(ctx context.Context, app *appctx.App, verbose bool) Ch
Name: "API Connectivity",
}

endpoint, epErr := app.Auth.AuthorizationEndpoint(ctx)
if epErr != nil {
check.Status = "fail"
check.Message = "Cannot determine authorization endpoint"
check.Hint = fmt.Sprintf("Error: %v", epErr)
return check
}

start := time.Now()
_, err := app.SDK.Authorization().GetInfo(ctx, nil)
_, err := app.SDK.Authorization().GetInfo(ctx, &basecamp.GetInfoOptions{
Endpoint: endpoint,
})
latency := time.Since(start)

if err != nil {
Expand Down
30 changes: 3 additions & 27 deletions internal/commands/people.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package commands

import (
"fmt"
"os"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -41,39 +40,16 @@ func NewMeCmd() *cobra.Command {
return cmd
}

// defaultLaunchpadBaseURL is the default Launchpad base URL.
const defaultLaunchpadBaseURL = "https://launchpad.37signals.com"

// getLaunchpadBaseURL returns the Launchpad base URL.
// Can be overridden via BASECAMP_LAUNCHPAD_URL for testing.
func getLaunchpadBaseURL() string {
if url := os.Getenv("BASECAMP_LAUNCHPAD_URL"); url != "" {
return url
}
return defaultLaunchpadBaseURL
}

func runMe(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())

if !app.Auth.IsAuthenticated() {
return output.ErrAuth("Not authenticated. Run: basecamp auth login")
}

// Determine authorization endpoint based on OAuth type
var endpoint string
oauthType := app.Auth.GetOAuthType()
switch oauthType {
case "bc3":
endpoint = app.Config.BaseURL + "/authorization.json"
case "launchpad":
endpoint = getLaunchpadBaseURL() + "/authorization.json"
case "":
// Handle authentication via BASECAMP_TOKEN where no OAuth type is stored.
// Treat as bc3 since BASECAMP_TOKEN implies direct API access.
endpoint = app.Config.BaseURL + "/authorization.json"
default:
return output.ErrAuth("Unknown OAuth type: " + oauthType)
endpoint, err := app.Auth.AuthorizationEndpoint(cmd.Context())
if err != nil {
return err
}

// Fetch identity and accounts using SDK
Expand Down
Loading
Loading