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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260629111348-cc8e9772e729
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260718061625-d15f023f64dc
Comment thread
jeremy marked this conversation as resolved.
github.com/basecamp/cli v0.2.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/glamour v1.0.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260629111348-cc8e9772e729 h1:bYGNjaxnlTTDUzn7SNgBGawMFkQ0UZB1Kfw3WJWi8eY=
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260629111348-cc8e9772e729/go.mod h1:39n0Qo1z9IWCnIIJGgTu19OukfnFbvJk27c2+4KzDMI=
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260718061625-d15f023f64dc h1:QGxZGLVQktaOpJDyKhj7/AY0oVqcBkL8jeFxlSn5zhs=
github.com/basecamp/basecamp-sdk/go v0.7.4-0.20260718061625-d15f023f64dc/go.mod h1:8VLJY4RwlazhxmA5rb37e6fOC0U6vwg3yjeFyT5xBW0=
github.com/basecamp/cli v0.2.1 h1:8GyehPVtsTXla0oOPu4QgXRjwwzJ99prlByvyi+0HRQ=
github.com/basecamp/cli v0.2.1/go.mod h1:p8tt/DatJ2LAzWO6N6tNfV8x3gF5T3IxDTo+U8FfWPo=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
Expand Down
45 changes: 35 additions & 10 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,13 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) (*LoginResult, e
return nil, err
}

// Device-only authorization servers omit the authorization endpoint.
// Assert authorization-code capability before scope handling and client
// registration so an unsupported flow can't trigger DCR side effects.
if oauthCfg.AuthorizationEndpoint == nil || *oauthCfg.AuthorizationEndpoint == "" {
return nil, output.ErrAuth("OAuth server does not advertise an authorization endpoint (authorization-code flow unsupported)")
}

// Apply provider-aware scope rules
effectiveScope := opts.Scope
if oauthType == "launchpad" {
Expand Down Expand Up @@ -532,26 +539,39 @@ func (m *Manager) Logout() error {
}

func (m *Manager) discoverOAuth(ctx context.Context, log func(string)) (*oauth.Config, string, error) {
// The SDK binds the discovered issuer to this string code-point exact
// (RFC 8414), so a trailing slash in the configured base URL would
// mismatch the server's issuer and incorrectly fall back to Launchpad.
baseURL := config.NormalizeBaseURL(m.cfg.BaseURL)
discoverer := oauth.NewDiscoverer(m.httpClient)
cfg, err := discoverer.Discover(ctx, m.cfg.BaseURL)
cfg, err := discoverer.Discover(ctx, baseURL)
if err != nil {
log(fmt.Sprintf("warning: OAuth discovery failed for %s, using Launchpad fallback", m.cfg.BaseURL))
log(fmt.Sprintf("warning: OAuth discovery failed for %s, using Launchpad fallback", baseURL))
// Fallback to Launchpad
lpURL, lpErr := m.launchpadURL()
if lpErr != nil {
return nil, "", lpErr
}
authzEndpoint := lpURL + "/authorization/new"
fallbackCfg := &oauth.Config{
AuthorizationEndpoint: lpURL + "/authorization/new",
AuthorizationEndpoint: &authzEndpoint,
TokenEndpoint: lpURL + "/authorization/token",
}
log(fmt.Sprintf("Authenticating via launchpad (%s)", fallbackCfg.AuthorizationEndpoint))
log(fmt.Sprintf("Authenticating via launchpad (%s)", authzEndpoint))
return fallbackCfg, "launchpad", nil
}
log(fmt.Sprintf("Authenticating via bc3 (%s)", cfg.AuthorizationEndpoint))
log(fmt.Sprintf("Authenticating via bc3 (%s)", strOrEmpty(cfg.AuthorizationEndpoint)))
return cfg, "bc3", nil
}

// strOrEmpty returns the value of p, or "" when p is nil.
func strOrEmpty(p *string) string {
if p == nil {
return ""
}
return *p
}

func (m *Manager) launchpadURL() (string, error) {
if u := os.Getenv("BASECAMP_LAUNCHPAD_URL"); u != "" {
if err := hostutil.RequireSecureURL(u); err != nil {
Expand All @@ -573,10 +593,10 @@ func (m *Manager) loadClientCredentials(ctx context.Context, oauthCfg *oauth.Con
}

// Register new client via DCR
if oauthCfg.RegistrationEndpoint == "" {
if oauthCfg.RegistrationEndpoint == nil || *oauthCfg.RegistrationEndpoint == "" {
return nil, output.ErrAuth("OAuth server does not support Dynamic Client Registration")
}
return m.registerBC3Client(ctx, oauthCfg.RegistrationEndpoint, opts)
return m.registerBC3Client(ctx, *oauthCfg.RegistrationEndpoint, opts)
}

// Launchpad: resolve client credentials from env vars
Expand Down Expand Up @@ -792,17 +812,22 @@ func (m *Manager) saveBC3Client(creds *ClientCredentials) error {
}

func (m *Manager) buildAuthURL(cfg *oauth.Config, oauthType, scope, state, codeChallenge, clientID string, opts *LoginOptions) (string, error) {
u, err := url.Parse(cfg.AuthorizationEndpoint)
// Login asserts this before any side effects; kept as a defensive check
// for other callers.
if cfg.AuthorizationEndpoint == nil || *cfg.AuthorizationEndpoint == "" {
return "", output.ErrAuth("OAuth server does not advertise an authorization endpoint (authorization-code flow unsupported)")
}
u, err := url.Parse(*cfg.AuthorizationEndpoint)
if err != nil {
return "", output.ErrAuth(fmt.Sprintf("invalid authorization endpoint %q: %v", cfg.AuthorizationEndpoint, err))
return "", output.ErrAuth(fmt.Sprintf("invalid authorization endpoint %q: %v", *cfg.AuthorizationEndpoint, err))
}

// The authorization endpoint comes from the server-controlled discovery
// document and is later dispatched to the OS browser handler (xdg-open /
// open). Restrict it to https (or http on loopback for local development)
// so a hostile discovery doc can't hand the OS a file:// (or other) URL.
if !isSecureEndpointURL(u) {
return "", output.ErrAuth(fmt.Sprintf("invalid authorization endpoint %q: must be an absolute https URL (or http on loopback)", cfg.AuthorizationEndpoint))
return "", output.ErrAuth(fmt.Sprintf("invalid authorization endpoint %q: must be an absolute https URL (or http on loopback)", *cfg.AuthorizationEndpoint))
}

q := u.Query()
Expand Down
119 changes: 106 additions & 13 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ func (sl *syncLogger) snapshot() []string {
return cp
}

// strPtr returns a pointer to s, for oauth.Config optional endpoint fields.
func strPtr(s string) *string {
return &s
}

// newTestStore creates a file-backed credential store for testing.
func newTestStore(t *testing.T, dir string) *Store {
t.Helper()
Expand Down Expand Up @@ -341,9 +346,9 @@ func TestCredentialsJSON(t *testing.T) {
func TestOAuthConfigJSON(t *testing.T) {
cfg := &oauth.Config{
Issuer: "https://issuer.example.com",
AuthorizationEndpoint: "https://auth.example.com/authorize",
AuthorizationEndpoint: strPtr("https://auth.example.com/authorize"),
TokenEndpoint: "https://auth.example.com/token",
RegistrationEndpoint: "https://auth.example.com/register",
RegistrationEndpoint: strPtr("https://auth.example.com/register"),
ScopesSupported: []string{"read", "write"},
}

Expand Down Expand Up @@ -432,6 +437,40 @@ func TestDiscoverOAuth_PropagatesInsecureLaunchpadError(t *testing.T) {
assert.Contains(t, err.Error(), "BASECAMP_LAUNCHPAD_URL")
}

// TestDiscoverOAuth_NormalizesTrailingSlash guards the issuer binding: the
// SDK binds the discovered issuer to the requested base URL code-point exact
// (RFC 8414), so a configured BaseURL with a trailing slash must be
// normalized before discovery — otherwise BC3 discovery mismatches the
// server's issuer and incorrectly falls back to Launchpad.
func TestDiscoverOAuth_NormalizesTrailingSlash(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/oauth-authorization-server" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
base := "http://" + r.Host
fmt.Fprintf(w, `{
"issuer": "%s",
"authorization_endpoint": "%s/authorize",
"token_endpoint": "%s/token"
}`, base, base, base)
}))
defer srv.Close()

cfg := config.Default()
cfg.BaseURL = srv.URL + "/"

m := &Manager{cfg: cfg, httpClient: srv.Client()}

noop := func(string) {}
oauthCfg, oauthType, err := m.discoverOAuth(context.Background(), noop)
require.NoError(t, err)
assert.Equal(t, "bc3", oauthType, "trailing-slash BaseURL must not fall back to Launchpad")
require.NotNil(t, oauthCfg.AuthorizationEndpoint)
assert.Equal(t, srv.URL+"/authorize", *oauthCfg.AuthorizationEndpoint)
}

func TestResolveOAuthCallback(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -584,7 +623,7 @@ func TestResolveClientCredentials(t *testing.T) {
func TestBuildAuthURL_UsesResolvedRedirectURI(t *testing.T) {
m := &Manager{cfg: config.Default(), httpClient: http.DefaultClient}
oauthCfg := &oauth.Config{
AuthorizationEndpoint: "https://auth.example.com/authorize",
AuthorizationEndpoint: strPtr("https://auth.example.com/authorize"),
}
opts := &LoginOptions{RedirectURI: "http://localhost:9999/my-callback"}

Expand All @@ -608,7 +647,7 @@ func TestBuildAuthURL_RejectsUnsafeScheme(t *testing.T) {
}
for _, endpoint := range accepted {
t.Run("accepts "+endpoint, func(t *testing.T) {
oauthCfg := &oauth.Config{AuthorizationEndpoint: endpoint}
oauthCfg := &oauth.Config{AuthorizationEndpoint: strPtr(endpoint)}
_, err := m.buildAuthURL(oauthCfg, "bc3", "read", "state", "challenge", "cid", opts)
require.NoError(t, err)
})
Expand All @@ -627,7 +666,7 @@ func TestBuildAuthURL_RejectsUnsafeScheme(t *testing.T) {
}
for _, endpoint := range rejected {
t.Run("rejects "+endpoint, func(t *testing.T) {
oauthCfg := &oauth.Config{AuthorizationEndpoint: endpoint}
oauthCfg := &oauth.Config{AuthorizationEndpoint: strPtr(endpoint)}
_, err := m.buildAuthURL(oauthCfg, "bc3", "read", "state", "challenge", "cid", opts)
require.Error(t, err)
})
Expand All @@ -649,7 +688,7 @@ func TestBuildAuthURL_RejectsMalformedEndpoint(t *testing.T) {
}
for _, endpoint := range malformed {
t.Run("rejects "+endpoint, func(t *testing.T) {
oauthCfg := &oauth.Config{AuthorizationEndpoint: endpoint}
oauthCfg := &oauth.Config{AuthorizationEndpoint: strPtr(endpoint)}
_, err := m.buildAuthURL(oauthCfg, "bc3", "read", "state", "challenge", "cid", opts)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid authorization endpoint")
Expand Down Expand Up @@ -1074,7 +1113,7 @@ func TestLoadClientCredentials_BC3_CustomRedirect_SkipsStoredClient(t *testing.T
storedCreds := &ClientCredentials{ClientID: "stored-id", ClientSecret: "stored-secret"}
require.NoError(t, m.saveBC3Client(storedCreds))

oauthCfg := &oauth.Config{RegistrationEndpoint: srv.URL + "/register"}
oauthCfg := &oauth.Config{RegistrationEndpoint: strPtr(srv.URL + "/register")}

// Custom redirect: should skip stored client and do DCR
opts := &LoginOptions{RedirectURI: "http://localhost:7777/cb"}
Expand Down Expand Up @@ -1204,10 +1243,11 @@ func TestLoginRemoteMode(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
base := "http://" + r.Host
fmt.Fprintf(w, `{
"issuer": "%s",
"authorization_endpoint": "%s/authorize",
"token_endpoint": "%s/token",
"registration_endpoint": "%s/register"
}`, base, base, base)
}`, base, base, base, base)
case "/register":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"client_id":"test-client","client_secret":"test-secret"}`)
Expand Down Expand Up @@ -1291,10 +1331,11 @@ func TestLoginRemoteMode_PromptWording(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
base := "http://" + r.Host
fmt.Fprintf(w, `{
"issuer": "%s",
"authorization_endpoint": "%s/authorize",
"token_endpoint": "%s/token",
"registration_endpoint": "%s/register"
}`, base, base, base)
}`, base, base, base, base)
case "/register":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"client_id":"test-client","client_secret":"test-secret"}`)
Expand Down Expand Up @@ -1367,10 +1408,11 @@ func TestLoginRemoteMode_StateMismatch(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
base := "http://" + r.Host
fmt.Fprintf(w, `{
"issuer": "%s",
"authorization_endpoint": "%s/authorize",
"token_endpoint": "%s/token",
"registration_endpoint": "%s/register"
}`, base, base, base)
}`, base, base, base, base)
case "/register":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"client_id":"test-client"}`)
Expand Down Expand Up @@ -1428,10 +1470,11 @@ func TestLoginRemoteMode_EmptyInput(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
base := "http://" + r.Host
fmt.Fprintf(w, `{
"issuer": "%s",
"authorization_endpoint": "%s/authorize",
"token_endpoint": "%s/token",
"registration_endpoint": "%s/register"
}`, base, base, base)
}`, base, base, base, base)
case "/register":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"client_id":"test-client"}`)
Expand Down Expand Up @@ -1487,10 +1530,11 @@ func TestLoginRemoteMode_CustomRedirectURI(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
base := "http://" + r.Host
fmt.Fprintf(w, `{
"issuer": "%s",
"authorization_endpoint": "%s/authorize",
"token_endpoint": "%s/token",
"registration_endpoint": "%s/register"
}`, base, base, base)
}`, base, base, base, base)
case "/register":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"client_id":"test-client"}`)
Expand Down Expand Up @@ -1707,10 +1751,11 @@ func TestLoginBC3DefaultsToRead(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
base := "http://" + r.Host
fmt.Fprintf(w, `{
"issuer": "%s",
"authorization_endpoint": "%s/authorize",
"token_endpoint": "%s/token",
"registration_endpoint": "%s/register"
}`, base, base, base)
}`, base, base, base, base)
case "/register":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"client_id":"test-client"}`)
Expand Down Expand Up @@ -2049,6 +2094,54 @@ func TestRefreshLocked_EmptyOAuthTypeDefaultsToLaunchpad(t *testing.T) {
assert.Contains(t, body, "client_id="+launchpadClientID)
}

// TestLoginDeviceOnlyServerFailsBeforeRegistration asserts the
// authorization-code capability check fires immediately after discovery: a
// device-only server (no authorization_endpoint) fails Login with an auth
// error before Dynamic Client Registration is attempted.
func TestLoginDeviceOnlyServerFailsBeforeRegistration(t *testing.T) {
var mu sync.Mutex
var requests []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
requests = append(requests, r.URL.Path)
mu.Unlock()
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
base := "http://" + r.Host
fmt.Fprintf(w, `{
"issuer": "%s",
"token_endpoint": "%s/token",
"device_authorization_endpoint": "%s/device",
"registration_endpoint": "%s/register",
"grant_types_supported": ["urn:ietf:params:oauth:grant-type:device_code"]
}`, base, base, base, base)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()

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

cfg := &config.Config{BaseURL: srv.URL}
m := NewManager(cfg, srv.Client())
m.store = newTestStore(t, tmpDir)

_, err := m.Login(context.Background(), LoginOptions{Logger: func(string) {}})
require.Error(t, err)
assert.Contains(t, err.Error(), "does not advertise an authorization endpoint")

var outErr *output.Error
require.ErrorAs(t, err, &outErr)
assert.Equal(t, output.CodeAuth, outErr.Code)

mu.Lock()
defer mu.Unlock()
assert.NotContains(t, requests, "/register", "capability check must fire before Dynamic Client Registration")
}

func TestLoginRejectsInvalidScope(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{BaseURL: "https://3.basecampapi.com"}
Expand Down
Loading
Loading