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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,9 @@ op read "op://Vault/Item/credential" | basecamp auth login --with-client-credent
The login mints once, which is what proves the client id and secret: a refused
mint stores nothing. `--account` is required when the profile does not exist
yet. `basecamp auth logout` forgets the credential; there is no useful
revocation, since the same client would mint another — rotate the client
secret in Basecamp to end an agent's access.
revocation, since the same client would mint another — disconnect the agent in
Basecamp to end its access, which kills its client secret and every token
minted from it.

### Containers, CI and scheduled jobs

Expand Down
2 changes: 1 addition & 1 deletion internal/auth/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ func TestAgentRevokeIsRefusedAndKeepsTheCredential(t *testing.T) {
err := m.RevokeStored(context.Background())
require.Error(t, err)
assert.Equal(t, output.CodeUsage, output.AsError(err).Code)
assert.Contains(t, err.Error(), "rotate the client secret")
assert.Contains(t, err.Error(), "disconnect the agent in Basecamp")
assert.Empty(t, as.revokeCalls(), "a revocation was sent for an agent self-token")

creds, loadErr := m.store.Load(key)
Expand Down
6 changes: 4 additions & 2 deletions internal/auth/revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ const (
// RevokeSkippedAgent: an agent self-token is minted on demand from a
// client id and secret, so revoking one accomplishes nothing — the
// same client mints another on the next command. Ending an agent's
// access means rotating the client secret in Basecamp.
// access means disconnecting it in Basecamp, which kills the client
// secret and every token minted from it. (Basecamp has no separate
// "rotate secret": reconnecting is the rotation.)
RevokeSkippedAgent = "agent"
)

Expand Down Expand Up @@ -194,7 +196,7 @@ func (m *Manager) RevokeStored(ctx context.Context) error {
"Forget the credential locally instead: basecamp auth logout")
case RevokeSkippedAgent:
return output.ErrUsageHint("An agent self-token is not worth revoking: the client that minted it can mint another",
"Forget the credential locally instead (basecamp auth logout), and rotate the client secret in Basecamp to end the agent's access")
"Forget the credential locally instead (basecamp auth logout), and disconnect the agent in Basecamp to end its access")
}
if err := m.Revoke(ctx, creds); err != nil {
// Keep the failure's taxonomy — a transport failure or a 5xx
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -1607,7 +1607,7 @@ func describeLogout(done string, result *auth.LogoutResult) (summary string, fie
summary = done + " (forgot the imported token; it stays valid until revoked in Basecamp)"
case result.Skipped == auth.RevokeSkippedAgent:
fields["reason"] = result.Skipped
summary = done + " (forgot the agent credential; rotate the client secret in Basecamp to end its access)"
summary = done + " (forgot the agent credential; it stays connected until you disconnect the agent in Basecamp)"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
case result.Err != nil:
fields["reason"] = result.Err.Error()
fields["remaining"] = result.Remaining
Expand Down
4 changes: 2 additions & 2 deletions internal/commands/auth_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ func newAuthAgentCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "agent",
Short: "Connect this computer to a Basecamp agent",
Long: `Connect this computer to a Basecamp agent — an identity your Basecamp
administrator created — so commands run as that agent.
Long: `Connect this computer to a Basecamp agent — your own personal agent, or
one your Basecamp administrator created — so commands run as that agent.

The connection hands this CLI the agent's own OAuth client, which mints the
access tokens it spends. Nobody pastes a credential: you approve the
Expand Down
23 changes: 23 additions & 0 deletions internal/commands/auth_logout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,19 @@ func bc5LogoutCredentials(s *revocationServer) *auth.Credentials {
}
}

// agentLogoutCredentials is a connected agent's stored credential: its own
// client id and secret, and a self-token minted from them.
func agentLogoutCredentials(s *revocationServer) *auth.Credentials {
return &auth.Credentials{
AccessToken: "agent-at",
OAuthType: "agent",
ClientID: "bc-agent-1",
ClientSecret: "agent-secret",
TokenEndpoint: s.srv.URL + "/oauth/tokens",
Scope: "full",
}
}

func decodeLogoutJSON(t *testing.T, buf *bytes.Buffer) map[string]any {
t.Helper()
var envelope struct {
Expand Down Expand Up @@ -181,6 +194,16 @@ func TestAuthLogoutHumanCopy(t *testing.T) {
assert.False(t, app.Auth.IsAuthenticated())
})

t.Run("agent", func(t *testing.T) {
s := startRevocationServer(t)
app, buf := newLogoutTestApp(t, s, output.FormatStyled, agentLogoutCredentials(s))
require.NoError(t, runLogout(t, app))
assert.Contains(t, buf.String(), "Logged out (forgot the agent credential; it stays connected until you disconnect the agent in Basecamp)")
assert.NotContains(t, buf.String(), "rotate", "Basecamp has no rotate-secret control to send anyone to")
assert.Empty(t, s.revoked(), "an agent self-token is minted on demand; revoking one ends nothing")
assert.False(t, app.Auth.IsAuthenticated())
})

t.Run("not logged in", func(t *testing.T) {
s := startRevocationServer(t)
app, buf := newLogoutTestApp(t, s, output.FormatStyled, nil)
Expand Down
Loading