Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b1ddc87
Import personal access tokens with auth login --with-token
jeremy Sep 2, 2026
14c33b8
Verify a login before storing it
jeremy Sep 2, 2026
a2e8a69
Bind what the token was verified for, and keep ordinary logins best-e…
jeremy Sep 2, 2026
a2defe8
Keep the server's expiry, refuse stray arguments and non-global bindings
jeremy Sep 2, 2026
17cad54
Judge a profile binding by the global file's own entry, refuse near-e…
jeremy Sep 2, 2026
709798b
Refuse to rewrite a malformed config, and limit the expiry refusal to…
jeremy Sep 2, 2026
283c4f9
Pin an origin only, refuse a non-object profiles value, and name app.…
jeremy Sep 2, 2026
45bc77b
Prove the config can take a profile before profile create logs in
jeremy Sep 2, 2026
313c5b0
Bump basecamp-sdk to main and send --login-hint on the wire
jeremy Sep 3, 2026
78e1a65
Merge remote-tracking branch 'origin/main' into auth-login-with-token
jeremy Sep 9, 2026
7784461
Record the pinned SDK revision in the MCP model provenance
jeremy Sep 9, 2026
e26b374
Strip a trailing CR only as part of a CRLF pair
jeremy Sep 9, 2026
362cc81
Refuse a null config, and prove the file is writable before anything …
jeremy Sep 9, 2026
50b4fe8
Refuse a login whose override disagrees with the profile's binding, O…
jeremy Sep 9, 2026
d33707a
Let profile create assert the identity it signs in as
jeremy Sep 9, 2026
da5db35
Return only the error from writableGlobalProfiles, since no caller re…
jeremy Sep 9, 2026
35bda36
Bump google.golang.org/grpc to v1.83.2 for CVE-2026-84445
jeremy Sep 9, 2026
a81be7c
Reject a bare query or fragment delimiter on the pinned issuer
jeremy Sep 9, 2026
071a546
Say an imported token reported no expiry, not that it never expires
jeremy Sep 9, 2026
fffcde5
Name the config preflight for what it checks
jeremy Sep 9, 2026
73da0c7
Recompute the Nix vendorHash for the SDK pin and grpc bump
jeremy Sep 9, 2026
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
7 changes: 7 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -1945,13 +1945,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 @@ -1966,6 +1968,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 @@ -10740,13 +10743,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 @@ -10761,6 +10766,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 Expand Up @@ -11927,6 +11933,7 @@ FLAG basecamp profile create --base-url type=string
FLAG basecamp profile create --cache-dir type=string
FLAG basecamp profile create --count type=bool
FLAG basecamp profile create --device-code type=bool
FLAG basecamp profile create --expect-identity type=string
FLAG basecamp profile create --help type=bool
FLAG basecamp profile create --hints type=bool
FLAG basecamp profile create --ids-only type=bool
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,34 @@ 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. `basecamp profile create <name> --expect-identity <id>` does the same
for a profile that does not exist yet. `--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 +246,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
4 changes: 2 additions & 2 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.16.0
github.com/basecamp/basecamp-sdk/go v0.16.1-0.20260903193203-47e7ca381a49
Comment thread
jeremy marked this conversation as resolved.
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 @@ -141,7 +141,7 @@ require (
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.83.1 // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
)
8 changes: 4 additions & 4 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.16.0 h1:6MfdBf+tgDtfzSHLDhJO9yqsCExNvtWUlK6gzTKzMvM=
github.com/basecamp/basecamp-sdk/go v0.16.0/go.mod h1:Cs9DV8iRJaVT4+IQXGZTeyG2nQAV2kQda7GHuBOeonY=
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 @@ -508,8 +508,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
36 changes: 27 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,31 @@ 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()
// The effective base URL, not the one SDK was built with: a profile
// being created sets its own in memory before its login runs.
cfg.BaseURL = a.Config.BaseURL
return basecamp.NewClient(&cfg, provider, a.SDKOptions...)
Comment thread
Copilot marked this conversation as resolved.
}

// 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
Loading