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
6 changes: 6 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -1929,13 +1929,15 @@ FLAG basecamp auth login --agent type=bool
FLAG basecamp auth login --cache-dir type=string
FLAG basecamp auth login --count type=bool
FLAG basecamp auth login --device-code type=bool
FLAG basecamp auth login --expect-identity type=string
FLAG basecamp auth login --help type=bool
FLAG basecamp auth login --hints type=bool
FLAG basecamp auth login --ids-only type=bool
FLAG basecamp auth login --in type=string
FLAG basecamp auth login --jq type=string
FLAG basecamp auth login --json type=bool
FLAG basecamp auth login --local type=bool
FLAG basecamp auth login --login-hint type=string
FLAG basecamp auth login --markdown type=bool
FLAG basecamp auth login --md type=bool
FLAG basecamp auth login --no-browser type=bool
Expand All @@ -1950,6 +1952,7 @@ FLAG basecamp auth login --stats type=bool
FLAG basecamp auth login --styled type=bool
FLAG basecamp auth login --todolist type=string
FLAG basecamp auth login --verbose type=count
FLAG basecamp auth login --with-token type=bool
FLAG basecamp auth logout --account type=string
FLAG basecamp auth logout --agent type=bool
FLAG basecamp auth logout --cache-dir type=string
Expand Down Expand Up @@ -10660,13 +10663,15 @@ FLAG basecamp login --agent type=bool
FLAG basecamp login --cache-dir type=string
FLAG basecamp login --count type=bool
FLAG basecamp login --device-code type=bool
FLAG basecamp login --expect-identity type=string
FLAG basecamp login --help type=bool
FLAG basecamp login --hints type=bool
FLAG basecamp login --ids-only type=bool
FLAG basecamp login --in type=string
FLAG basecamp login --jq type=string
FLAG basecamp login --json type=bool
FLAG basecamp login --local type=bool
FLAG basecamp login --login-hint type=string
FLAG basecamp login --markdown type=bool
FLAG basecamp login --md type=bool
FLAG basecamp login --no-browser type=bool
Expand All @@ -10681,6 +10686,7 @@ FLAG basecamp login --stats type=bool
FLAG basecamp login --styled type=bool
FLAG basecamp login --todolist type=string
FLAG basecamp login --verbose type=count
FLAG basecamp login --with-token type=bool
FLAG basecamp logout --account type=string
FLAG basecamp logout --agent type=bool
FLAG basecamp logout --cache-dir type=string
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,33 @@ basecamp auth login --scope full # Full read+write access (default; ignored by L
basecamp auth token # Print token for scripts
```

`--expect-identity <id>` makes any login assert who it authenticated as: the
new credential is checked before it is stored, and on a mismatch nothing is
written (a profile's previous credential is untouched) and the command exits
non-zero. `--login-hint <email>` names the account to sign in as on the
device-flow approval page (sent as `login_hint`; it steers the sign-in page and
never authenticates on its own; ignored by Launchpad).

### Personal access tokens

A [personal access token](https://app.basecamp.com/my/access_tokens) can be
imported instead of running OAuth — the shape for bots, CI, and any machine
that should never sign in interactively. The token is read from stdin (never
an argument), verified against the server — who it authenticates as, and that
it can reach the profile's account — and only then stored under a named
profile, with whatever expiry the server reports for it:

```bash
op read "op://Vault/Item/credential" | basecamp auth login --with-token -P bot --account 999
op read "op://Vault/Item/credential" | basecamp auth login --with-token -P bot --account 999 --expect-identity 12345 --json
```

`--account` is required when the profile does not exist yet. `--json` returns
an envelope with the profile, account, identity and person, `oauth_type`,
`scope`, and `expires_at` (the expiry the server reports for the token, or
`null` when it reports none). A token has no refresh token, so near a reported
expiry the CLI refuses it and asks for a fresh import.

### Multiple Identities

Use named profiles when the same machine or agent gateway needs more than one Basecamp identity. Each profile has its own stored OAuth credentials and can be selected per command:
Expand All @@ -218,6 +245,12 @@ To use your own OAuth app (e.g., a custom Launchpad integration):

Both `BASECAMP_OAUTH_CLIENT_ID` and `BASECAMP_OAUTH_CLIENT_SECRET` must be set together.

`BASECAMP_OAUTH_ISSUER=https://app.basecamp.com` pins the OAuth authorization
server and skips discovery, so `basecamp auth login` reaches a server that is
serving piloted clients but not yet advertising itself (discovery still 404s).
It is a temporary escape hatch for that dark pilot, not a configuration
surface, and will be removed once the server advertises its metadata.

## AI Agent Integration

`basecamp` works with any AI agent that can run shell commands.
Expand Down
29 changes: 29 additions & 0 deletions e2e/auth.bats
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,35 @@ load test_helper
assert_output_contains "default full"
}

@test "basecamp auth login --help shows --with-token, --expect-identity, and --login-hint" {
run basecamp auth login --help
assert_success
assert_output_contains "--with-token"
assert_output_contains "--expect-identity"
assert_output_contains "--login-hint"
assert_output_contains "op read"
}

@test "basecamp auth login --with-token requires a profile" {
run env -u BASECAMP_PROFILE basecamp auth login --with-token </dev/null
assert_failure
assert_json_value '.error' '--with-token stores the token under a named profile'
assert_json_value '.code' 'usage'
}

@test "basecamp auth login --with-token needs --account to create the profile" {
run basecamp auth login --with-token -P bot </dev/null
assert_failure
assert_json_value '.error' 'Profile "bot" does not exist'
assert_json_value '.code' 'usage'
}

@test "basecamp auth login --with-token rejects --device-code" {
run basecamp auth login --with-token --device-code </dev/null
assert_failure
assert_output_contains "with-token"
}

@test "basecamp auth login rejects --device-code --local" {
run basecamp auth login --device-code --local
assert_failure
Expand Down
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.0.9
charm.land/lipgloss/v2 v2.0.6
github.com/basecamp/basecamp-sdk/go v0.15.0
github.com/basecamp/basecamp-sdk/go v0.16.1-0.20260903193203-47e7ca381a49
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d
github.com/basecamp/surfguard/go v0.1.0
Expand Down Expand Up @@ -108,7 +108,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/oapi-codegen/runtime v1.6.0 // indirect
github.com/oapi-codegen/runtime v1.7.0 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
Expand All @@ -130,9 +130,9 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.opentelemetry.io/otel v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/otel/trace v1.46.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
Expand Down
24 changes: 12 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,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.15.0 h1:Yxp3WM7rZ7PDcXrOTc4dI9EFOqwHojpqVJ/zbdXnoVg=
github.com/basecamp/basecamp-sdk/go v0.15.0/go.mod h1:00mgcmi89PlnHnLJNwcJjwryo4W5bYJtA2FoqVgHP54=
github.com/basecamp/basecamp-sdk/go v0.16.1-0.20260903193203-47e7ca381a49 h1:acgFif/siLT3R1/XWHdXLGGsE4x7HqKCvSaXw6j7xHo=
github.com/basecamp/basecamp-sdk/go v0.16.1-0.20260903193203-47e7ca381a49/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY=
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d h1:jAzDrCCzDpIwhbFT1xVVs0z2xpXoDEkomHfKB2bUUp8=
github.com/basecamp/cli v0.2.2-0.20260828230226-767413fc712d/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE=
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4=
Expand Down Expand Up @@ -348,8 +348,8 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=
github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k=
github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
Expand Down Expand Up @@ -451,16 +451,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.step.sm/crypto v0.77.7 h1:6azC+pD678Vjju8yXnMDHCZJ+HzFaEmL3sCryiezTIA=
go.step.sm/crypto v0.77.7/go.mod h1:OW/2sEHwTtDKq70PvSQ5B0JGy/CrLyDKOiVy3YvZMTQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
Expand Down
33 changes: 24 additions & 9 deletions internal/appctx/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ type App struct {

// Flags holds the global flag values
Flags GlobalFlags

// SDKOptions are the client options SDK was built with, so a second
// client — one verifying a credential that is not stored yet — rides
// the same transport, hooks, and user agent.
SDKOptions []basecamp.ClientOption
}

// GlobalFlags holds values for global CLI flags.
Expand Down Expand Up @@ -126,11 +131,12 @@ func NewApp(cfg *config.Config) *App {
CacheDir: cfg.CacheDir,
CacheEnabled: cfg.CacheEnabled,
}
sdkClient := basecamp.NewClient(sdkCfg, &authAdapter{mgr: authMgr},
sdkOptions := []basecamp.ClientOption{
basecamp.WithHooks(hooks),
basecamp.WithTransport(transport),
basecamp.WithUserAgent(version.UserAgent()+" "+basecamp.DefaultUserAgent),
)
basecamp.WithUserAgent(version.UserAgent() + " " + basecamp.DefaultUserAgent),
}
sdkClient := basecamp.NewClient(sdkCfg, &authAdapter{mgr: authMgr}, sdkOptions...)

// Create name resolver using SDK client and account ID
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)
Expand All @@ -147,19 +153,28 @@ func NewApp(cfg *config.Config) *App {
}

return &App{
Config: cfg,
Auth: authMgr,
SDK: sdkClient,
Names: nameResolver,
Collector: collector,
Hooks: cliHooks,
Config: cfg,
Auth: authMgr,
SDK: sdkClient,
SDKOptions: sdkOptions,
Names: nameResolver,
Collector: collector,
Hooks: cliHooks,
Output: output.New(output.Options{
Format: format,
Writer: os.Stdout,
}),
}
}

// SDKClientFor returns a client configured like SDK but authenticating with
// the given provider instead of the stored credential — the way to exercise
// a token before deciding whether to keep it.
func (a *App) SDKClientFor(provider basecamp.TokenProvider) *basecamp.Client {
cfg := a.SDK.Config()
return basecamp.NewClient(&cfg, provider, a.SDKOptions...)
Comment on lines +173 to +175

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not doing this — and it is the third variation of one finding (this thread, auth.go:696 and auth.go:742 all say: a server that reflects the bearer token into an error body could get the candidate token into an error message or a trace file). The failure it would prevent needs the operator's own configured base URL to be a server that echoes Authorization headers back; Basecamp does not, and an operator pointing the CLI at a hostile host has already handed it the token in the request. The same error text reaches verbose output and trace files for every command the CLI runs with the stored credential, so a verification-only redaction would leave the class where it already lives; if the class is worth closing, the place is the observability hooks (redact bearer-shaped substrings once), not three sites in this PR. Leaving this open for Jeremy's call on whether hook-level redaction is wanted.

}

// ApplyFlags applies global flag values to the app configuration.
func (a *App) ApplyFlags() {
// Apply output format from flags (order matters: specific modes first)
Expand Down
Loading