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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,11 @@ 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. To end its access,
disconnect its connection in Basecamp (on My > Agent for your own agent, in
Adminland for a shared one), which kills that connection's secret and every
token minted from it. Where My > Agent lists several connections, disconnect
the one this profile used: Disconnect all ends every one of them.
Comment thread
jeremy marked this conversation as resolved.

### Connecting an agent to this computer

Expand Down
6 changes: 3 additions & 3 deletions internal/auth/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,9 @@ func TestAgentMintRefusesARedirect(t *testing.T) {
assert.Zero(t, *sinkCalls, "the client secret followed a redirect")
}

// TestAgentLogoutSaysRotatingTheSecretIsTheRemedy: revoking one self-token
// TestAgentLogoutForgetsWithoutRevoking: revoking one self-token
// accomplishes nothing while the client that minted it can mint another.
func TestAgentLogoutSaysRotatingTheSecretIsTheRemedy(t *testing.T) {
func TestAgentLogoutForgetsWithoutRevoking(t *testing.T) {
as := startDeviceAS(t)
m := newDeviceTestManager(t, as.srv.URL)
key := storeAgent(t, m, agentCredential(as.srv.URL+"/oauth/token", time.Now().Add(time.Hour)))
Expand All @@ -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, output.AsError(err).Hint, "disconnect this connection in Basecamp")
assert.Empty(t, as.revokeCalls(), "a revocation was sent for an agent self-token")

creds, loadErr := m.store.Load(key)
Expand Down
11 changes: 8 additions & 3 deletions internal/auth/revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ const (
RevokeSkippedImported = "imported_token"
// 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.
// same client mints another on the next command. What ends this
// credential's access is disconnecting its connection in Basecamp,
// which kills that connection's secret and every token minted from it.
// Once an agent can hold several connections, neither older remedy is
// right: rotating the client secret (Basecamp has no control for it)
// rewrites only the first connection's, and disconnecting the whole
// agent ends every other computer's connection too.
RevokeSkippedAgent = "agent"
)

Expand Down Expand Up @@ -194,7 +199,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 this connection 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; disconnect this connection in Basecamp to end its access)"
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; disconnect this connection in Basecamp to end its access)")
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