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
2 changes: 2 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -2086,6 +2086,7 @@ FLAG basecamp auth agent connect --todolist type=string
FLAG basecamp auth agent connect --verbose type=count
FLAG basecamp auth login --account type=string
FLAG basecamp auth login --agent type=bool
FLAG basecamp auth login --as type=string
FLAG basecamp auth login --cache-dir type=string
FLAG basecamp auth login --client-id type=string
FLAG basecamp auth login --count type=bool
Expand Down Expand Up @@ -11268,6 +11269,7 @@ FLAG basecamp lineup update --todolist type=string
FLAG basecamp lineup update --verbose type=count
FLAG basecamp login --account type=string
FLAG basecamp login --agent type=bool
FLAG basecamp login --as type=string
FLAG basecamp login --cache-dir type=string
FLAG basecamp login --client-id type=string
FLAG basecamp login --count type=bool
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,20 @@ redirect. Login falls back to Launchpad's authorization-code flow only when
no modern OAuth issuer is advertised for the server; once a modern issuer is
selected, login failures surface loudly rather than silently falling back.

At a terminal, against Basecamp's own server, login first asks who this
computer connects as: **your agent** (recommended, and what Enter picks), which
works in Basecamp as itself, or **yourself**. Choosing your agent runs the same
approval as `basecamp auth agent connect`, under the `-P` profile you name or
else one called `agent`. On that page you pick your agent, create it if you
don't have one yet, or pick a team agent. If Basecamp can't connect an agent for
you, login offers to connect you instead. `--as agent` or `--as me` answers
ahead of time. Scripts, CI and anything else without a terminal connect you, as
they always have, unless they pass `--as agent`.

```bash
basecamp auth login # Authenticate with Basecamp (full access)
basecamp auth login --as agent # Connect this computer as your agent
basecamp auth login --as me # Connect as yourself, without asking
basecamp auth login --scope read # Read-only access (ignored by Launchpad)
basecamp auth login --scope full # Full read+write access (default; ignored by Launchpad)
basecamp auth token # Print token for scripts
Expand Down Expand Up @@ -266,7 +278,7 @@ already connected on this computer under a profile, name it
another. The same steps, scriptable:

```bash
basecamp auth agent connect -P my-agent # one approval
basecamp auth agent connect -P my-agent # one approval (or: basecamp auth login --as agent -P my-agent)
basecamp connect setup -P my-agent --serve 12345 # who may give it work, which projects
basecamp connect -P my-agent # the watcher
```
Expand Down
5 changes: 4 additions & 1 deletion install.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ Interactive installs authenticate during Step 1. If setup was skipped, did not f
basecamp auth login
```

This opens browser OAuth. Grant access when prompted.
This opens browser OAuth. Grant access when prompted. At a terminal it first
asks whether this computer connects as your agent or as yourself; an agent
installing for a person runs it without a terminal, which connects the person,
or passes `--as agent` or `--as me` to choose.

**Verify:**
```bash
Expand Down
29 changes: 26 additions & 3 deletions internal/auth/agent_connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ type AgentConnectOptions struct {
// browser, unless the flow is not launching one at all.
BrowserLauncher func(url string) error

// Provider, when set, is the authorization server the caller already
// discovered (DiscoverProvider), used instead of discovering again.
Provider *Provider

// Logger receives the operator's half of the ceremony. Nil suppresses
// it — which, since the ceremony is the operator reading a link and a
// code, is only ever right for a test.
Expand Down Expand Up @@ -257,7 +261,7 @@ func (m *Manager) ConnectAgent(ctx context.Context, opts AgentConnectOptions) (*
log = func(string) {}
}

disc, err := m.discoverOAuth(ctx, log)
disc, err := m.discoverOrUse(ctx, opts.Provider, log)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -600,7 +604,10 @@ func (m *Manager) pollAgentConnection(ctx context.Context, client *http.Client,
case "slow_down":
return nil, interval + agentConnectSlowDownStep, nil
case "access_denied":
return nil, 0, agentConnectFailure("The agent connection was declined",
// Basecamp answers this both when the operator declined and when
// its page told them agents can't be connected for them and they
// acknowledged it. Nothing in the answer tells the two apart.
return nil, 0, noAgentConnection("The agent connection was declined",
"Run the connect command again and approve it in the browser.")
case "expired_token":
return nil, 0, agentConnectExpired()
Expand Down Expand Up @@ -772,7 +779,7 @@ func agentConnectRefusal(op string, answer *agentConnectAnswer) error {
detail = "token error: " + code
}
if answer.status == http.StatusServiceUnavailable {
return agentConnectFailure(op+": "+detail,
return noAgentConnection(op+": "+detail,
"This Basecamp is not issuing agent connections right now. Ask your Basecamp administrator, or sign in as a bot user instead: basecamp auth login --device-code.")
Comment thread
jeremy marked this conversation as resolved.
}
return answer.failure(op + ": " + detail)
Expand Down Expand Up @@ -822,6 +829,22 @@ func agentConnectExpired() error {
"Run the connect command again for a new code, and approve it before it expires.")
}

// ErrNoAgentConnection marks a ceremony that ended with no agent connected
// and nothing handed over: the operator declined, Basecamp told them agents
// can't be connected for them, or this Basecamp isn't issuing connections.
// A caller that offered the agent as one choice can offer the other; no
// code was redeemed, so there is nothing to clean up.
var ErrNoAgentConnection = errors.New("no agent was connected")

// noAgentConnection is agentConnectFailure for a ceremony that connected no
// agent, marked as ErrNoAgentConnection.
func noAgentConnection(msg, hint string) error {
err := output.ErrAuth(msg)
err.Hint = hint
err.Cause = ErrNoAgentConnection
return err
}

// agentConnectFailure is an auth-class refusal carrying a remedy of its
// own, rather than the package default — every remedy here is "run the
// connect command again", not "log in".
Expand Down
24 changes: 24 additions & 0 deletions internal/auth/agent_connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@ func TestConnectAgentReportsADecline(t *testing.T) {
_, err := m.ConnectAgent(context.Background(), connectOptions(&collectLogger{}, newTestClock()))
require.Error(t, err)
assert.Contains(t, err.Error(), "declined")
assert.ErrorIs(t, err, ErrNoAgentConnection, "a decline connects no agent, so a caller can offer the person instead")
assert.Len(t, as.calls(&as.pollForms), 1, "a decline ends the ceremony")
assertNoAgentCredential(t, m)
}
Expand All @@ -563,6 +564,7 @@ func TestConnectAgentReportsAnExpiredCode(t *testing.T) {
_, err := m.ConnectAgent(context.Background(), connectOptions(&collectLogger{}, newTestClock()))
require.Error(t, err)
assert.Contains(t, err.Error(), "expired")
assert.NotErrorIs(t, err, ErrNoAgentConnection, "an unanswered code is not Basecamp declining an agent")
// The lifetime is the server's to choose, so the remedy must not
// promise one this code may not have had.
var e *output.Error
Expand Down Expand Up @@ -662,6 +664,27 @@ func TestConnectAgentBacksOffWhenThrottled(t *testing.T) {
assert.Equal(t, []time.Duration{time.Second, 30 * time.Second}, clock.waits())
}

// TestConnectAgentWaitsOutAPollAnswered503: only the intake's 503 means
// Basecamp isn't issuing connections, and only it says no agent was
// connected. A poll answered 503 is the server's trouble while an approval
// may already stand: the poll backs off and carries on, never reaching the
// refusal that marks ErrNoAgentConnection.
func TestConnectAgentWaitsOutAPollAnswered503(t *testing.T) {
as := startConnectAS(t)
as.poll = func(call int) (int, string) {
if call == 0 {
return http.StatusServiceUnavailable, ``
}
return http.StatusOK, connectionJSON(scopeFull)
}
m := connectManager(t, as)

result, err := m.ConnectAgent(context.Background(), connectOptions(&collectLogger{}, newTestClock()))
require.NoError(t, err)
assert.Equal(t, "999", result.AccountID)
assert.Len(t, as.calls(&as.pollForms), 2)
}

// TestConnectAgentRefusesATokenEndpointOnAnotherOrigin: the poll carries
// the device code and is answered with a client secret. A response body
// naming another host for it is not followed, and the handover never
Expand Down Expand Up @@ -710,6 +733,7 @@ func TestConnectAgentReportsARefusedIntake(t *testing.T) {
var e *output.Error
require.ErrorAs(t, err, &e)
assert.Contains(t, e.Hint, "not issuing agent connections")
assert.ErrorIs(t, err, ErrNoAgentConnection)
assert.Empty(t, as.calls(&as.pollForms))
}

Expand Down
42 changes: 41 additions & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,10 @@ type LoginOptions struct {
// ignores it.
LoginHint string

// Provider, when set, is the authorization server the caller already
// discovered (DiscoverProvider), used instead of discovering again.
Provider *Provider

// Verify, when set, is called with the freshly issued access token and
// its provider type ("bc5" or "launchpad") before anything is stored. A
// non-nil error aborts the login and nothing is written: the credential
Expand Down Expand Up @@ -964,7 +968,7 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) (*LoginResult, e

credKey := m.credentialKey()

disc, err := m.discoverOAuth(ctx, opts.log)
disc, err := m.discoverOrUse(ctx, opts.Provider, opts.log)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1393,6 +1397,42 @@ func (m *Manager) ImportToken(ctx context.Context, token, scope, userID, userEma
})
}

// Provider is the authorization server OAuth discovery selected, found
// ahead of a login so the caller can decide what to run against it — `auth
// login` asks who this computer connects as only where Basecamp's own server
// can connect an agent — and then hand it to Login or ConnectAgent, which
// use it instead of discovering again.
type Provider struct{ disc *discovery }

// Basecamp reports whether discovery selected Basecamp's own authorization
// server, the one that runs the device flow and connects agents, rather
// than the Launchpad fallback.
func (p *Provider) Basecamp() bool {
return p != nil && p.disc.oauthType == oauthTypeBC5
}

// DiscoverProvider runs OAuth discovery for the configured base URL, saying
// what it selected through log as a login would.
func (m *Manager) DiscoverProvider(ctx context.Context, log func(string)) (*Provider, error) {
if log == nil {
log = func(string) {}
}
disc, err := m.discoverOAuth(ctx, log)
if err != nil {
return nil, err
}
return &Provider{disc: disc}, nil
}

// discoverOrUse is the discovery a flow runs under: the provider its caller
// already found, or a fresh discovery.
func (m *Manager) discoverOrUse(ctx context.Context, p *Provider, log func(string)) (*discovery, error) {
if p != nil {
return p.disc, nil
}
return m.discoverOAuth(ctx, log)
}

// discovery is the outcome of provider selection: the OAuth config to use
// and which login flow it drives.
type discovery struct {
Expand Down
100 changes: 30 additions & 70 deletions internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,7 @@ func buildLoginCmd(use string) *cobra.Command {
var clientID string
var expectIdentity string
var loginHint string
var connectAs string

cmd := &cobra.Command{
Use: use,
Expand All @@ -636,8 +637,21 @@ in CI, or on a host with no display the browser is skipped and the link is yours
to open on any device — your phone included; --local forces a launch anyway,
--no-browser skips it. Ctrl-C cancels the wait.

At a terminal, against Basecamp's own server, login first asks who this
computer connects as: your agent (recommended), which works in Basecamp as
itself, or yourself. Choosing your agent runs the same approval as
` + "`basecamp auth agent connect`" + `, under the -P profile you name or else one
called "agent". If Basecamp can't connect an agent for you, login offers to
connect you instead. --as agent or --as me answers ahead of time. Everywhere
else (a script, CI, no terminal) login connects you, as it always has, unless
--as agent is given. It doesn't ask when signing a profile that already holds
your own login back in, or with --login-hint or --expect-identity, which only
apply to you.

Examples:
basecamp auth login # Browser (or device) flow
basecamp auth login --as agent # Connect this computer as your agent
basecamp auth login --as me # Connect as yourself, without asking
basecamp auth login --device-code # Headless: approve the printed code from any device
basecamp auth login --expect-identity 12345 # Refuse the login unless it is this identity

Expand Down Expand Up @@ -673,6 +687,10 @@ client credentials are what is kept.
if err != nil {
return err
}
as, err := parseConnectAs(connectAs)
if err != nil {
return err
}

// --client-id names which agent is being authenticated, so
// running an ordinary interactive login with it set would sign
Expand All @@ -693,77 +711,16 @@ client credentials are what is kept.
return runLoginClientCredentials(cmd, app, clientID, scope)
}

if err := refuseMachineOutputLogin(app, "the login command"); err != nil {
return err
}
if err := refuseNonInteractiveLogin(deviceCode); err != nil {
return err
}
if expect != 0 && os.Getenv("BASECAMP_TOKEN") != "" {
return errEnvTokenShadows("--expect-identity cannot be checked while BASECAMP_TOKEN is set")
}
if name := app.Config.ActiveProfile; name != "" {
existing, ok := app.Config.Profiles[name]
if !ok {
return output.ErrUsageHint(fmt.Sprintf("Profile %q does not exist", name),
fmt.Sprintf("Create it with `basecamp profile create %s`, or import a token: `... | basecamp auth login --with-token -P %s --account <id>`.", name, name))
}
if err := requireProfileBinding(app, name, existing); err != nil {
return err
}
}

if deviceCode {
remote = true
}

w := cmd.OutOrStdout()
r := output.NewRendererWithTheme(w, false, tui.ResolveTheme(tui.DetectDark()))

if app.Config.ActiveProfile != "" {
fmt.Fprintln(w, r.Summary.Render(fmt.Sprintf("Starting authentication for profile %q...", app.Config.ActiveProfile)))
} else {
fmt.Fprintln(w, r.Summary.Render("Starting Basecamp authentication..."))
}

// With an expectation the login is assertive: the token is
// checked before it is stored, and a mismatch stores nothing.
// Without one the identity line stays informational.
verifier := &loginVerifier{app: app, expectIdentity: expect, account: app.Config.AccountID, strict: expect != 0}
ctx, stop := loginContext(cmd)
result, err := app.Auth.Login(ctx, auth.LoginOptions{
Scope: scope,
NoBrowser: noBrowser,
Remote: remote,
Local: local,
LoginHint: loginHint,
Logger: func(msg string) { fmt.Fprintln(w, msg) },
Progress: w,
Verify: verifier.verify,
return runBrowserLogin(cmd, app, browserLogin{
as: as,
scope: scope,
noBrowser: noBrowser,
remote: remote,
local: local,
deviceCode: deviceCode,
loginHint: loginHint,
expect: expect,
})
err = loginOutcome(ctx, err, w, r)
stop()
if err != nil {
return err
}

fmt.Fprintln(w)
fmt.Fprintln(w, r.Success.Render("Authentication successful!"))

if result.Scope != "" {
fmt.Fprintln(w, r.Muted.Render(fmt.Sprintf("Access: %s", result.Scope)))
}

if who := verifier.who; who != nil {
if who.PersonID != 0 {
_ = app.Auth.SetUserIdentity(cmd.Context(), strconv.FormatInt(who.PersonID, 10), who.Email)
}
fmt.Fprintln(w, r.Data.Render("Logged in as: "+who.label()))
}

printAgentNudge(w, r)

return nil
},
}

Expand All @@ -774,6 +731,9 @@ client credentials are what is kept.
cmd.Flags().StringVar(&clientID, "client-id", "", "OAuth client ID of the agent to authenticate as (with --with-client-credentials)")
cmd.Flags().StringVar(&expectIdentity, "expect-identity", "", "Identity ID the login must authenticate as; otherwise store nothing")
cmd.Flags().StringVar(&loginHint, "login-hint", "", "Email address to sign in as on the device-flow approval page (ignored by Launchpad)")
cmd.Flags().StringVar(&connectAs, "as", "", "Who this computer connects as: 'agent' (your Basecamp agent) or 'me' (yourself). Asked when run at a terminal; otherwise 'me'")
cmd.MarkFlagsMutuallyExclusive("with-token", "as")
cmd.MarkFlagsMutuallyExclusive("with-client-credentials", "as")
cmd.MarkFlagsMutuallyExclusive("remote", "local")
cmd.MarkFlagsMutuallyExclusive("device-code", "local")
for _, flag := range []string{"device-code", "remote", "local", "no-browser", "login-hint"} {
Expand Down
Loading
Loading