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
175 changes: 175 additions & 0 deletions internal/agentauth/agentconfirmsicons/agent_confirms_icons.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package agentconfirmsicons

import (
"errors"
"fmt"
"strings"
"time"
"unicode"

"github.com/tollbit/cli/internal/agentauth"
"github.com/tollbit/cli/internal/agentauth/common"
"github.com/tollbit/cli/internal/client/auth"
"github.com/tollbit/cli/internal/cliruntime"
"github.com/tollbit/cli/internal/tokens/agent"
)

type (
ConsentStrategyConfig struct {
AuthClient *auth.Client
Runtime *cliruntime.Runtime
AutoOpenBrowser bool
UseRefreshTokens bool
}

ConsentStrategy struct {
authClient *auth.Client
runtime *cliruntime.Runtime
autoOpenBrowser bool
useRefreshTokens bool
}
)

func NewConsentStrategy(cfg ConsentStrategyConfig) (*ConsentStrategy, error) {
if cfg.AuthClient == nil {
return nil, errors.New("auth client is required")
}
if cfg.Runtime == nil {
return nil, errors.New("runtime is required")
}
return &ConsentStrategy{
authClient: cfg.AuthClient,
runtime: cfg.Runtime,
autoOpenBrowser: cfg.AutoOpenBrowser,
useRefreshTokens: cfg.UseRefreshTokens,
}, nil
}

func (s ConsentStrategy) Method() agentauth.ConsentMethod {
return agentauth.ConsentMethodAgentConfirmsIcons
}

func (s ConsentStrategy) AutoOpensBrowser() bool {
return s.autoOpenBrowser
}

func (s ConsentStrategy) SupportsOBORetry() bool {
return false
}

func (s ConsentStrategy) SupportsDetachedCompletion() bool {
return true
}

func (s ConsentStrategy) Guidance() agentauth.ConsentGuidance {
return agentauth.ConsentGuidance{
FlowLabel: "detached icon confirmation",
LoginInstructions: "For remote authorization, run `tollbit auth login` and relay only the printed consent URL to the end user. Do not send the valid icon vocabulary to the user; it is for the agent's spelling reference only.",
CompleteInstructions: "After the end user authorizes in their browser, they will read back three icon names. Verify the icon names against the list of valid icons previously provided in `auth login`. Then run `tollbit auth complete <first> <second> <third>` using those names in exactly the displayed order.",
CompleteArgsLabel: "pending auth plus three icon names",
Troubleshooting: []agentauth.ConsentGuidanceRow{
{Symptom: "unrecognized icon name", Meaning: "one submitted word did not match the icon manifest and no attempt was consumed", Action: "confirm spelling with the end user using the printed vocabulary, then rerun `tollbit auth complete <first> <second> <third>`"},
{Symptom: "authorization denied", Meaning: "the icons or order were wrong and the challenge is dead", Action: "restart with `tollbit auth login`"},
{Symptom: "authorization still pending", Meaning: "the end user has not clicked Authorize yet", Action: "wait for the end user to finish, then rerun `tollbit auth complete <first> <second> <third>`"},
},
}
}

func (s ConsentStrategy) AuthorizeOBO(inv agentauth.Invocation, identity auth.AgentIdentity, baseToken agent.Token) (auth.AgentTokenResponse, error) {
ctx := inv.Context()
codeVerifier, codeChallenge, err := common.GeneratePKCE()
if err != nil {
return auth.AgentTokenResponse{}, err
}
scope := ""
if s.useRefreshTokens {
scope = "offline_access"
}

startResp, err := s.authClient.StartAgentConsentAgentConfirmsIcons(ctx, baseToken, auth.ConsentAgentConfirmsIconsStartRequest{
CodeChallenge: codeChallenge,
CodeChallengeMethod: common.PKCEChallengeMethod,
Scope: scope,
})
if err != nil {
return auth.AgentTokenResponse{}, err
}
if strings.TrimSpace(startResp.ChallengeID) == "" {
return auth.AgentTokenResponse{}, errors.New("auth did not return a consent challenge id")
}
if strings.TrimSpace(startResp.ConsentURL) == "" {
return auth.AgentTokenResponse{}, errors.New("auth did not return a consent URL")
}
if len(startResp.IconNames) == 0 {
return auth.AgentTokenResponse{}, errors.New("auth did not return valid icon names")
}

stdout := inv.OutOrStdout()
fmt.Fprintf(stdout, "Authorize agent: %s\n\n", identity.Name)
if s.AutoOpensBrowser() {
if err := s.runtime.OpenBrowser(startResp.ConsentURL); err != nil {
fmt.Fprintf(stdout, "Could not open your browser automatically: %v\n", err)
fmt.Fprintln(stdout, "Open this URL in the end user's browser to continue:")
} else {
fmt.Fprintln(stdout, "Opened authorization page in your browser.")
fmt.Fprintln(stdout, "If it did not open, visit:")
}
} else {
fmt.Fprintln(stdout, "Relay ONLY this URL to the end user. The end user must open it in their browser, sign in, and click Authorize:")
}
fmt.Fprintf(stdout, "%s\n\n", startResp.ConsentURL)
fmt.Fprintln(stdout, "VALID ICON NAMES (for the agent's reference; use to correct the user's spelling; do NOT send this list to the user):")
fmt.Fprintln(stdout, strings.Join(startResp.IconNames, " "))
fmt.Fprintln(stdout)
fmt.Fprintln(stdout, "After the user reads back the three icons shown on the page, run `tollbit auth complete <first> <second> <third>`.")
fmt.Fprintln(stdout, "Order matters.")

pending := agentauth.PendingConsent{
Method: agentauth.ConsentMethodAgentConfirmsIcons,
ChallengeID: startResp.ChallengeID,
AgentIdentity: identity,
BaseToken: baseToken.RawToken,
CodeVerifier: codeVerifier,
UseRefreshTokens: s.useRefreshTokens,
CreatedAt: time.Now().UTC(),
ExpiresAt: startResp.ExpiresAt,
IconNames: startResp.IconNames,
}
return auth.AgentTokenResponse{}, agentauth.AuthorizationPendingError{Pending: pending}
}

func (s ConsentStrategy) CompleteDetached(inv agentauth.Invocation, pending agentauth.PendingConsent, input agentauth.CompleteDetachedInput) (auth.AgentTokenResponse, error) {
if pending.Method != agentauth.ConsentMethodAgentConfirmsIcons {
return auth.AgentTokenResponse{}, fmt.Errorf("pending consent method %q is not supported by agent-confirms-icons", pending.Method)
}
iconNames := normalizeIconNames(input.IconNames)
if len(iconNames) != 3 {
return auth.AgentTokenResponse{}, fmt.Errorf("expected exactly 3 icon names; run `tollbit auth complete <first> <second> <third>` in the order shown to the user. Valid icon names: %s", strings.Join(pending.IconNames, " "))
}
var ua *string
if pending.AgentIdentity.UserAgent != "" {
ua = &pending.AgentIdentity.UserAgent
}
return s.authClient.RedeemAgentConsentAgentConfirmsIcons(inv.Context(), agent.Token{RawToken: pending.BaseToken}, auth.ConsentAgentConfirmsIconsTokenRequest{
AgentIdentifier: pending.AgentIdentity.Name,
ChallengeID: pending.ChallengeID,
IconNames: iconNames,
CodeVerifier: pending.CodeVerifier,
UA: ua,
WBA: pending.AgentIdentity.WBA,
})
}

func normalizeIconNames(input []string) []string {
iconNames := make([]string, 0, len(input))
for _, name := range input {
name = strings.TrimFunc(strings.TrimSpace(name), func(r rune) bool {
return unicode.IsPunct(r) || unicode.IsSpace(r)
})
name = strings.ToUpper(name)
if name != "" {
iconNames = append(iconNames, name)
}
}
return iconNames
}
219 changes: 219 additions & 0 deletions internal/agentauth/agentconfirmsicons/agent_confirms_icons_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package agentconfirmsicons

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/golang-jwt/jwt/v5"
"github.com/tollbit/cli/internal/agentauth"
"github.com/tollbit/cli/internal/client/auth"
"github.com/tollbit/cli/internal/cliruntime"
"github.com/tollbit/cli/internal/configuration"
"github.com/tollbit/cli/internal/tokens/agent"
)

func TestConsentStrategyStartsDetachedAuthorization(t *testing.T) {
baseToken := testAgentJWT(t)
var sawStart bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/agent/v1/consent/agent-confirms-icons/start" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer "+baseToken {
t.Fatalf("unexpected authorization: %q", r.Header.Get("Authorization"))
}
var body auth.ConsentAgentConfirmsIconsStartRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.CodeChallenge == "" || body.CodeChallengeMethod != "S256" || body.Scope != "offline_access" {
t.Fatalf("unexpected body: %#v", body)
}
sawStart = true
_ = json.NewEncoder(w).Encode(auth.ConsentAgentConfirmsIconsStartResponse{
ChallengeID: "ach_123",
ConsentURL: "https://auth.example.test/oauth/consent/agent-confirms-icons?consent_challenge=ach_123",
ExpiresAt: "2026-07-20T12:00:00Z",
IconNames: []string{"ANCHOR", "FOX", "STAR"},
})
}))
defer srv.Close()
client, err := auth.New(auth.ClientConfig{BaseURL: srv.URL})
if err != nil {
t.Fatal(err)
}
strategy, err := NewConsentStrategy(ConsentStrategyConfig{
AuthClient: client,
Runtime: newTestRuntime(t),
UseRefreshTokens: true,
})
if err != nil {
t.Fatal(err)
}
inv := &testInvocation{}

_, err = strategy.AuthorizeOBO(inv, auth.AgentIdentity{Name: "agent-test"}, agent.Token{RawToken: baseToken})

var pending agentauth.AuthorizationPendingError
if !errors.As(err, &pending) {
t.Fatalf("expected pending authorization error, got %v", err)
}
if !sawStart || pending.Pending.ChallengeID != "ach_123" || pending.Pending.CodeVerifier == "" || len(pending.Pending.IconNames) != 3 {
t.Fatalf("unexpected pending state: %#v", pending.Pending)
}
stdout := inv.stdout.String()
for _, want := range []string{"Authorize agent: agent-test", "Relay ONLY this URL", "VALID ICON NAMES", "do NOT send this list to the user", "ANCHOR FOX STAR", "tollbit auth complete <first> <second> <third>", "Order matters"} {
if !strings.Contains(stdout, want) {
t.Fatalf("expected stdout to contain %q, got %q", want, stdout)
}
}
if strategy.Guidance().CompleteArgsLabel == "" {
t.Fatal("expected complete args guidance label")
}
}

func TestConsentStrategyCompleteDetachedRedeems(t *testing.T) {
baseToken := testAgentJWT(t)
var sawRedeem bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/agent/v1/tokens/identity" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
icons := body["icon_names"].([]any)
if body["grant_type"] != "consent:agent_confirms_icons" || body["challenge_id"] != "ach_123" || body["code_verifier"] != "verifier" || icons[0] != "FOX" || icons[1] != "ANCHOR" || icons[2] != "STAR" {
t.Fatalf("unexpected body: %#v", body)
}
sawRedeem = true
_ = json.NewEncoder(w).Encode(auth.AgentTokenResponse{Token: "linked-token"})
}))
defer srv.Close()
strategy := newTestStrategy(t, srv.URL)

resp, err := strategy.CompleteDetached(&testInvocation{}, testPending(baseToken), agentauth.CompleteDetachedInput{IconNames: []string{"fox,", "Anchor", " STAR "}})
if err != nil {
t.Fatal(err)
}
if !sawRedeem || resp.Token != "linked-token" {
t.Fatalf("unexpected response: %#v", resp)
}
}

func TestCompleteDetachedRejectsBadCountWithoutServerCall(t *testing.T) {
baseToken := testAgentJWT(t)
var requests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
}))
defer srv.Close()
strategy := newTestStrategy(t, srv.URL)

for _, input := range [][]string{nil, []string{"fox"}, []string{"fox", "anchor"}, []string{"fox", "anchor", "star", "owl"}} {
_, err := strategy.CompleteDetached(&testInvocation{}, testPending(baseToken), agentauth.CompleteDetachedInput{IconNames: input})
if err == nil || !strings.Contains(err.Error(), "expected exactly 3 icon names") {
t.Fatalf("expected count error, got %v", err)
}
}
if requests != 0 {
t.Fatalf("expected no server requests, got %d", requests)
}
}

func TestNewConsentStrategyValidatesConfig(t *testing.T) {
client, err := auth.New(auth.ClientConfig{BaseURL: "https://auth.example.test"})
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
config ConsentStrategyConfig
wantErr string
}{
{name: "missing auth client", config: ConsentStrategyConfig{Runtime: newTestRuntime(t)}, wantErr: "auth client is required"},
{name: "missing runtime", config: ConsentStrategyConfig{AuthClient: client}, wantErr: "runtime is required"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewConsentStrategy(tt.config)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}

func newTestStrategy(t *testing.T, baseURL string) *ConsentStrategy {
t.Helper()
client, err := auth.New(auth.ClientConfig{BaseURL: baseURL})
if err != nil {
t.Fatal(err)
}
strategy, err := NewConsentStrategy(ConsentStrategyConfig{AuthClient: client, Runtime: newTestRuntime(t)})
if err != nil {
t.Fatal(err)
}
return strategy
}

func testPending(baseToken string) agentauth.PendingConsent {
return agentauth.PendingConsent{
Method: agentauth.ConsentMethodAgentConfirmsIcons,
ChallengeID: "ach_123",
AgentIdentity: auth.AgentIdentity{Name: "agent-test"},
BaseToken: baseToken,
CodeVerifier: "verifier",
IconNames: []string{"ANCHOR", "FOX", "STAR"},
}
}

type testInvocation struct {
stdout bytes.Buffer
stderr bytes.Buffer
}

func (i *testInvocation) Context() context.Context { return context.Background() }
func (i *testInvocation) OutOrStdout() io.Writer { return &i.stdout }
func (i *testInvocation) ErrOrStderr() io.Writer { return &i.stderr }

func testAgentJWT(t *testing.T) string {
t.Helper()
claims := agent.Claims{
RegisteredClaims: jwt.RegisteredClaims{Subject: "agent-test", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
TBT: "agent-token",
}
header := map[string]any{"alg": "none"}
return encodeJSONSegment(t, header) + "." + encodeJSONSegment(t, claims) + "." + base64.RawURLEncoding.EncodeToString([]byte("signature"))
}

func encodeJSONSegment(t *testing.T, value any) string {
t.Helper()
b, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}

func newTestRuntime(t *testing.T) *cliruntime.Runtime {
t.Helper()
rt, err := cliruntime.New(cliruntime.Config{
ConfiguredEndUserProximity: configuration.RuntimeEndUserProximityRemote,
StateDir: t.TempDir(),
})
if err != nil {
t.Fatal(err)
}
return rt
}
Loading