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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ tollbit auth logout
tollbit auth logout --all
```

Logging in again (`tollbit auth login`) mints a new token and revokes all previous ones — use it if you believe a token was exposed. `auth logout` revokes the current token; `auth logout --force` clears local credentials even if the server can't be reached (the token is then revoked at your next login or expires within 30 days).

`TOLLBIT_AGENT_DEFAULT_NAME` and `TOLLBIT_AGENT_DEFAULT_USER_AGENT` set fallback profile defaults. Saved profile overrides those defaults. `search` and `content` accept `--user-agent` as a per-request override.

`auth status --check` exits `0` when the token is valid, `1` when invalid/expired, and `2` when missing (no stdout).
Expand Down
37 changes: 27 additions & 10 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ type (
}

authLogoutOptions struct {
all bool
all bool
force bool
}
)

Expand Down Expand Up @@ -93,6 +94,7 @@ func NewAuthLogoutCommand(factory app.Factory) *cobra.Command {
},
}
cmd.Flags().BoolVar(&opts.all, "all", false, "also clear the persisted agent profile")
cmd.Flags().BoolVar(&opts.force, "force", false, "clear local credentials even if the server token could not be revoked")
return cmd
}

Expand Down Expand Up @@ -201,18 +203,33 @@ func runAuthLogout(cmd *cobra.Command, factory app.Factory, opts authLogoutOptio
return RuntimeError(err)
}
ctx := cmd.Context()

var clearErr error
successMsg := "Cleared agent token."
if opts.all {
if err := credentials.ClearIdentity(ctx); err != nil {
return RuntimeError(err)
}
fmt.Fprintln(cmd.OutOrStdout(), "Cleared agent profile and token.")
return nil
clearErr = credentials.ClearIdentity(ctx, opts.force)
successMsg = "Cleared agent profile and token."
} else {
clearErr = credentials.ClearAgentTokens(ctx, opts.force)
}
if err := credentials.ClearAgentTokens(ctx); err != nil {
return RuntimeError(err)

switch {
case clearErr == nil:
fmt.Fprintln(cmd.OutOrStdout(), successMsg)
return nil
case errors.Is(clearErr, agenttoken.ErrRevokeFailed) && opts.force:
fmt.Fprintln(cmd.OutOrStdout(), successMsg)
fmt.Fprintln(cmd.ErrOrStderr(),
"warning: could not revoke the token on the server. It will be revoked the next time you log in, or expires within 30 days.")
return nil
case errors.Is(clearErr, agenttoken.ErrRevokeFailed):
return RuntimeError(errors.New(
"could not reach the server to revoke your token; you are still logged in. " +
"Check your connection and run `tollbit auth logout` again. " +
"To clear local credentials without revoking, use --force (the token is revoked at your next login or expires within 30 days)."))
default:
return RuntimeError(clearErr)
}
fmt.Fprintln(cmd.OutOrStdout(), "Cleared agent token.")
return nil
}

func runAuthStatus(cmd *cobra.Command, factory app.Factory, opts authStatusOptions) error {
Expand Down
62 changes: 62 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,68 @@ func TestRunAuthLoginStatusAndLogout(t *testing.T) {
}
}

func TestRunAuthLogoutFailClosedAndForce(t *testing.T) {
storageDir := t.TempDir()
tokenPath := filepath.Join(storageDir, "agent-token.jwt")
refreshPath := filepath.Join(storageDir, "refresh-token.json")
if err := os.WriteFile(tokenPath, []byte(testAgentJWT(t)), 0o600); err != nil {
t.Fatal(err)
}
refreshRaw, err := json.Marshal(map[string]string{"refresh_token": "agrt_cli"})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(refreshPath, refreshRaw, 0o600); err != nil {
t.Fatal(err)
}

authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.RequestURI() != "/agent/v1/tokens/refresh/revoke" {
t.Fatalf("unexpected auth request: %s %s", r.Method, r.URL.RequestURI())
}
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{"detail": "revoke failed"})
}))
defer authSrv.Close()

t.Setenv(testAuthBaseURLEnvVar, authSrv.URL)
t.Setenv(testCredentialsStorageDirEnvVar, storageDir)

var stdout, stderr bytes.Buffer
code := executeTestCommand([]string{"auth", "logout"}, nil, &stdout, &stderr)
if code == 0 {
t.Fatalf("expected non-zero exit on revoke failure, got 0 stdout=%q stderr=%q", stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "still logged in") {
t.Fatalf("expected fail-closed message, got stderr=%q", stderr.String())
}
if _, err := os.Stat(tokenPath); err != nil {
t.Fatalf("expected agent token to remain, got err=%v", err)
}
if _, err := os.Stat(refreshPath); err != nil {
t.Fatalf("expected refresh token to remain, got err=%v", err)
}

stdout.Reset()
stderr.Reset()
code = executeTestCommand([]string{"auth", "logout", "--force"}, nil, &stdout, &stderr)
if code != 0 {
t.Fatalf("auth logout --force failed: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "Cleared agent token.") {
t.Fatalf("unexpected force logout stdout: %q", stdout.String())
}
if !strings.Contains(stderr.String(), "warning: could not revoke the token on the server") {
t.Fatalf("expected force warning on stderr, got %q", stderr.String())
}
if _, err := os.Stat(tokenPath); !os.IsNotExist(err) {
t.Fatalf("expected agent token removed under force, got err=%v", err)
}
if _, err := os.Stat(refreshPath); !os.IsNotExist(err) {
t.Fatalf("expected refresh token removed under force, got err=%v", err)
}
}

func testAgentJWT(t *testing.T) string {
t.Helper()
claims := struct {
Expand Down
32 changes: 25 additions & 7 deletions internal/credentials/agenttoken/clear.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,50 @@ package agenttoken

import (
"context"
"errors"
"fmt"

"github.com/rs/zerolog"
)

// ErrRevokeFailed indicates the refresh token could not be revoked on the
// server. Without force, ClearAuthTokens leaves local credentials intact so the
// user can retry logout. With force, local credentials are cleared and this is
// returned as a non-fatal signal so callers can warn about residual validity.
var ErrRevokeFailed = errors.New("refresh token could not be revoked on the server")

func (m *CredentialManager) Clear(ctx context.Context) error {
return m.clearAgentToken(ctx)
}

func (m *CredentialManager) ClearAgentTokens(ctx context.Context) error {
return m.ClearAuthTokens(ctx)
func (m *CredentialManager) ClearAgentTokens(ctx context.Context, force bool) error {
return m.ClearAuthTokens(ctx, force)
}

func (m *CredentialManager) ClearAuthTokens(ctx context.Context) error {
func (m *CredentialManager) ClearAuthTokens(ctx context.Context, force bool) error {
refresh, exists, err := m.readRefreshCredential(ctx)
if err != nil {
return err
}
var revokeErr error
if exists {
if err := m.revokeRefreshToken(ctx, refresh); err != nil {
return err
}
revokeErr = m.revokeRefreshToken(ctx, refresh)
}
if revokeErr != nil && !force {
// Fail closed: keep local credentials so logout can be retried.
return fmt.Errorf("%w: %w", ErrRevokeFailed, revokeErr)
}
if err := m.clearAgentToken(ctx); err != nil {
return err
}
return m.clearRefreshCredential(ctx)
if err := m.clearRefreshCredential(ctx); err != nil {
return err
}
if revokeErr != nil {
// force: local credentials cleared, but revocation failed — signal it.
return fmt.Errorf("%w: %w", ErrRevokeFailed, revokeErr)
}
return nil
}

func (m *CredentialManager) revokeRefreshToken(ctx context.Context, cred refreshCredential) error {
Expand Down
22 changes: 13 additions & 9 deletions internal/credentials/agenttoken/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func (m *CredentialManager) SaveIdentity(ctx context.Context, identity auth.Agen
if err := m.WriteIdentity(ctx, identity); err != nil {
return err
}
return m.ClearAuthTokens(ctx)
return m.ClearAuthTokens(ctx, false)
}

func (m *CredentialManager) WriteIdentity(ctx context.Context, identity auth.AgentIdentity) error {
Expand Down Expand Up @@ -84,21 +84,25 @@ func (m *CredentialManager) GetStoredIdentity(ctx context.Context) (auth.AgentId
return identity, true, nil
}

func (m *CredentialManager) ClearIdentity(ctx context.Context) error {
func (m *CredentialManager) ClearIdentity(ctx context.Context, force bool) error {
if err := ctx.Err(); err != nil {
return err
}
if err := m.ClearAuthTokens(ctx); err != nil {
return err
clearErr := m.ClearAuthTokens(ctx, force)
// Fail closed: revoke failed without force → tokens were left intact, so
// leave the identity in place too and let the whole logout be retried.
if clearErr != nil && errors.Is(clearErr, ErrRevokeFailed) && !force {
return clearErr
}
err := os.Remove(m.identityPath)
if os.IsNotExist(err) {
err = nil
// A non-revoke error is a real filesystem failure — surface it.
if clearErr != nil && !errors.Is(clearErr, ErrRevokeFailed) {
return clearErr
}
if err != nil {
// Tokens were cleared (success, or force): remove the identity file.
if err := os.Remove(m.identityPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("clear agent identity credential: %w", err)
}
return nil
return clearErr // nil on full success; ErrRevokeFailed signal on force
}

func validIdentity(id auth.AgentIdentity) (auth.AgentIdentity, error) {
Expand Down
Loading
Loading