diff --git a/internal/agentauth/agentconfirmsicons/agent_confirms_icons.go b/internal/agentauth/agentconfirmsicons/agent_confirms_icons.go new file mode 100644 index 0000000..0956b7c --- /dev/null +++ b/internal/agentauth/agentconfirmsicons/agent_confirms_icons.go @@ -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 ` 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 `"}, + {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 `"}, + }, + } +} + +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 `.") + 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 ` 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 +} diff --git a/internal/agentauth/agentconfirmsicons/agent_confirms_icons_test.go b/internal/agentauth/agentconfirmsicons/agent_confirms_icons_test.go new file mode 100644 index 0000000..ba14626 --- /dev/null +++ b/internal/agentauth/agentconfirmsicons/agent_confirms_icons_test.go @@ -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 ", "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 +} diff --git a/internal/agentauth/browserselecticon/browser_select_icon.go b/internal/agentauth/browserselecticon/browser_select_icon.go new file mode 100644 index 0000000..a046e76 --- /dev/null +++ b/internal/agentauth/browserselecticon/browser_select_icon.go @@ -0,0 +1,156 @@ +package browserselecticon + +import ( + "errors" + "fmt" + "strings" + "time" + + "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.ConsentMethodBrowserSelectIcon +} + +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 browser relay", + LoginInstructions: "For remote authorization, run `tollbit auth login`, then relay the printed URL and verification icon to the end user exactly as shown. Do not summarize, rename, or replace the icon.", + CompleteInstructions: "After the end user finishes authorization in their browser, run `tollbit auth complete` with no arguments.", + CompleteArgsLabel: "pending auth", + Troubleshooting: []agentauth.ConsentGuidanceRow{ + {Symptom: "authorization still pending", Meaning: "the end user has not finished browser consent", Action: "wait for the end user to finish, then rerun `tollbit auth complete`"}, + {Symptom: "authorization denied, expired, or invalid", Meaning: "the pending challenge can no longer be completed", Action: "restart with `tollbit auth login`"}, + }, + } +} + +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.StartAgentConsentBrowserSelectIcon(ctx, baseToken, auth.ConsentBrowserSelectIconStartRequest{ + 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") + } + + 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 your 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, "Open this URL in the end user's browser to continue:") + } + fmt.Fprintf(stdout, "%s\n\n", startResp.ConsentURL) + fmt.Fprintln(stdout, "Relay the following verification icon to the end user exactly. Do not summarize, rename, or replace it with an emoji. The name alone is not sufficient.") + fmt.Fprintln(stdout) + fmt.Fprintln(stdout, "BEGIN VERIFICATION ICON") + fmt.Fprintf(stdout, "Name: %s\n", startResp.CorrectIcon.Name) + if startResp.CorrectIcon.Art != "" { + fmt.Fprintf(stdout, "```text\n%s\n```\n", startResp.CorrectIcon.Art) + } + fmt.Fprintln(stdout, "END VERIFICATION ICON") + fmt.Fprintln(stdout) + fmt.Fprintln(stdout, "After the user completes authorization, run `tollbit auth complete`.") + + pending := agentauth.PendingConsent{ + Method: agentauth.ConsentMethodBrowserSelectIcon, + ChallengeID: startResp.ChallengeID, + AgentIdentity: identity, + BaseToken: baseToken.RawToken, + CodeVerifier: codeVerifier, + UseRefreshTokens: s.useRefreshTokens, + CreatedAt: time.Now().UTC(), + ExpiresAt: startResp.ExpiresAt, + CorrectIcon: startResp.CorrectIcon, + } + 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.ConsentMethodBrowserSelectIcon { + return auth.AgentTokenResponse{}, fmt.Errorf("pending consent method %q is not supported by browser-select-icon", pending.Method) + } + var ua *string + if pending.AgentIdentity.UserAgent != "" { + ua = &pending.AgentIdentity.UserAgent + } + return s.authClient.RedeemAgentConsentBrowserSelectIcon(inv.Context(), agent.Token{RawToken: pending.BaseToken}, auth.ConsentBrowserSelectIconTokenRequest{ + AgentIdentifier: pending.AgentIdentity.Name, + ChallengeID: pending.ChallengeID, + CodeVerifier: pending.CodeVerifier, + UA: ua, + WBA: pending.AgentIdentity.WBA, + }) +} diff --git a/internal/agentauth/browserselecticon/browser_select_icon_test.go b/internal/agentauth/browserselecticon/browser_select_icon_test.go new file mode 100644 index 0000000..ddb67f3 --- /dev/null +++ b/internal/agentauth/browserselecticon/browser_select_icon_test.go @@ -0,0 +1,209 @@ +package browserselecticon + +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, nil) + 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/browser-select-icon/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.ConsentBrowserSelectIconStartRequest + 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.ConsentBrowserSelectIconStartResponse{ + ChallengeID: "ach_123", + ConsentURL: "https://auth.example.test/oauth/consent/browser-select-icon?consent_challenge=ach_123", + ExpiresAt: "2026-07-20T12:00:00Z", + CorrectIcon: auth.AgentConsentIcon{ID: 17, Name: "ACORN", Art: "ACORN\n art"}, + }) + })) + 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), + AutoOpenBrowser: false, + 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 == "" || pending.Pending.CorrectIcon.Name != "ACORN" { + t.Fatalf("unexpected pending state: %#v", pending.Pending) + } + stdout := inv.stdout.String() + for _, want := range []string{ + "Authorize agent: agent-test", + "Open this URL in the end user's browser", + "Relay the following verification icon to the end user exactly", + "Do not summarize, rename, or replace it with an emoji", + "The name alone is not sufficient", + "BEGIN VERIFICATION ICON", + "Name: ACORN", + "ACORN\n art", + "END VERIFICATION ICON", + "tollbit auth complete", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("expected stdout to contain %q, got %q", want, stdout) + } + } + if strategy.AutoOpensBrowser() { + t.Fatal("browser-select-icon should not auto-open when configured false") + } + if strategy.SupportsOBORetry() { + t.Fatal("browser-select-icon should not support implicit OBO retry") + } + if strategy.Guidance().CompleteArgsLabel == "" { + t.Fatal("expected complete args guidance label") + } +} + +func TestConsentStrategyReportsAutoOpenBrowserConfig(t *testing.T) { + client, err := auth.New(auth.ClientConfig{BaseURL: "https://auth.example.test"}) + if err != nil { + t.Fatal(err) + } + strategy, err := NewConsentStrategy(ConsentStrategyConfig{ + AuthClient: client, + Runtime: newTestRuntime(t), + AutoOpenBrowser: true, + }) + if err != nil { + t.Fatal(err) + } + if !strategy.AutoOpensBrowser() { + t.Fatal("expected browser-select-icon to report auto-open enabled") + } +} + +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 { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} + +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, obo *agent.OBOClaims) string { + t.Helper() + claims := agent.Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "agent-test", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + TBT: "agent-token", + OBO: obo, + } + 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 +} diff --git a/internal/agentauth/common/pkce.go b/internal/agentauth/common/pkce.go new file mode 100644 index 0000000..d42c86d --- /dev/null +++ b/internal/agentauth/common/pkce.go @@ -0,0 +1,27 @@ +package common + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" +) + +const PKCEChallengeMethod = "S256" + +func GeneratePKCE() (string, string, error) { + verifier, err := RandomURLToken(48) + if err != nil { + return "", "", err + } + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + return verifier, challenge, nil +} + +func RandomURLToken(size int) (string, error) { + b := make([]byte, size) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/agentauth/obo.go b/internal/agentauth/obo.go index b687c07..349b90d 100644 --- a/internal/agentauth/obo.go +++ b/internal/agentauth/obo.go @@ -1,10 +1,105 @@ package agentauth import ( + "errors" + "fmt" + "time" + "github.com/tollbit/cli/internal/client/auth" "github.com/tollbit/cli/internal/tokens/agent" ) -type OBOAuthorizer interface { - AuthorizeOBO(inv Invocation, identity auth.AgentIdentity, baseToken agent.Token) (auth.AgentTokenResponse, error) +type ConsentMethod string + +const ( + ConsentMethodRedirect ConsentMethod = "redirect" + ConsentMethodBrowserSelectIcon ConsentMethod = "browser_select_icon" + ConsentMethodAgentConfirmsIcons ConsentMethod = "agent_confirms_icons" +) + +type ( + OBOAuthorizer interface { + SupportsOBORetry() bool + AuthorizeOBO(inv Invocation, identity auth.AgentIdentity, baseToken agent.Token) (auth.AgentTokenResponse, error) + CompleteDetached(inv Invocation, pending PendingConsent, input CompleteDetachedInput) (auth.AgentTokenResponse, error) + } + + ConsentStrategy interface { + Method() ConsentMethod + AutoOpensBrowser() bool + SupportsOBORetry() bool + SupportsDetachedCompletion() bool + Guidance() ConsentGuidance + AuthorizeOBO(inv Invocation, identity auth.AgentIdentity, baseToken agent.Token) (auth.AgentTokenResponse, error) + CompleteDetached(inv Invocation, pending PendingConsent, input CompleteDetachedInput) (auth.AgentTokenResponse, error) + } + + CompleteDetachedInput struct { + IconNames []string + } + + ConsentGuidance struct { + FlowLabel string + LoginInstructions string + CompleteInstructions string + CompleteArgsLabel string + Troubleshooting []ConsentGuidanceRow + } + + ConsentGuidanceRow struct { + Symptom string + Meaning string + Action string + } + + PendingConsent struct { + Method ConsentMethod `json:"method"` + ChallengeID string `json:"challenge_id"` + AgentIdentity auth.AgentIdentity `json:"agent_identity"` + BaseToken string `json:"base_token"` + CodeVerifier string `json:"code_verifier"` + UseRefreshTokens bool `json:"use_refresh_tokens"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt string `json:"expires_at,omitempty"` + CorrectIcon auth.AgentConsentIcon `json:"correct_icon"` + IconNames []string `json:"icon_names,omitempty"` + } + + AuthorizationPendingError struct { + Pending PendingConsent + } + + ConfiguredOBOAuthorizerConfig struct { + Strategy ConsentStrategy + } + + ConfiguredOBOAuthorizer struct { + strategy ConsentStrategy + } +) + +func (e AuthorizationPendingError) Error() string { + if e.Pending.ChallengeID == "" { + return "authorization pending" + } + return fmt.Sprintf("authorization pending for challenge %s", e.Pending.ChallengeID) +} + +func NewConfiguredOBOAuthorizer(cfg ConfiguredOBOAuthorizerConfig) (*ConfiguredOBOAuthorizer, error) { + if cfg.Strategy == nil { + return nil, errors.New("consent strategy is required") + } + return &ConfiguredOBOAuthorizer{strategy: cfg.Strategy}, nil +} + +func (a ConfiguredOBOAuthorizer) SupportsOBORetry() bool { + return a.strategy.SupportsOBORetry() +} + +func (a ConfiguredOBOAuthorizer) AuthorizeOBO(inv Invocation, identity auth.AgentIdentity, baseToken agent.Token) (auth.AgentTokenResponse, error) { + return a.strategy.AuthorizeOBO(inv, identity, baseToken) +} + +func (a ConfiguredOBOAuthorizer) CompleteDetached(inv Invocation, pending PendingConsent, input CompleteDetachedInput) (auth.AgentTokenResponse, error) { + return a.strategy.CompleteDetached(inv, pending, input) } diff --git a/internal/agentauth/browser_consent.go b/internal/agentauth/redirect/redirect.go similarity index 55% rename from internal/agentauth/browser_consent.go rename to internal/agentauth/redirect/redirect.go index 1dcfb0b..3e44a6d 100644 --- a/internal/agentauth/browser_consent.go +++ b/internal/agentauth/redirect/redirect.go @@ -1,55 +1,56 @@ -package agentauth +package redirect import ( "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" "errors" "fmt" - "net/url" - "os/exec" - "runtime" "strings" "time" + "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/oauth/loopback" "github.com/tollbit/cli/internal/tokens/agent" ) -const ( - pkceChallengeMethod = "S256" -) - -type BrowserConsentAuthorizerConfig struct { - AuthClient *auth.Client - CallbackAddress string - AutoOpenBrowser bool - Timeout time.Duration - UseRefreshTokens bool -} +type ( + ConsentStrategyConfig struct { + AuthClient *auth.Client + Runtime *cliruntime.Runtime + CallbackAddress string + AutoOpenBrowser bool + Timeout time.Duration + UseRefreshTokens bool + } -type BrowserConsentAuthorizer struct { - authClient *auth.Client - callbackAddress string - autoOpenBrowser bool - timeout time.Duration - useRefreshTokens bool -} + ConsentStrategy struct { + authClient *auth.Client + runtime *cliruntime.Runtime + callbackAddress string + autoOpenBrowser bool + timeout time.Duration + useRefreshTokens bool + } +) -func NewBrowserConsentAuthorizer(cfg BrowserConsentAuthorizerConfig) (*BrowserConsentAuthorizer, error) { +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") + } if strings.TrimSpace(cfg.CallbackAddress) == "" { return nil, errors.New("callback address is required") } if cfg.Timeout < 0 { return nil, errors.New("timeout must be non-negative") } - return &BrowserConsentAuthorizer{ + return &ConsentStrategy{ authClient: cfg.AuthClient, + runtime: cfg.Runtime, callbackAddress: strings.TrimSpace(cfg.CallbackAddress), autoOpenBrowser: cfg.AutoOpenBrowser, timeout: cfg.Timeout, @@ -57,7 +58,36 @@ func NewBrowserConsentAuthorizer(cfg BrowserConsentAuthorizerConfig) (*BrowserCo }, nil } -func (a BrowserConsentAuthorizer) AuthorizeOBO(inv Invocation, identity auth.AgentIdentity, baseToken agent.Token) (auth.AgentTokenResponse, error) { +func (a ConsentStrategy) Method() agentauth.ConsentMethod { + return agentauth.ConsentMethodRedirect +} + +func (a ConsentStrategy) AutoOpensBrowser() bool { + return a.autoOpenBrowser +} + +func (a ConsentStrategy) SupportsOBORetry() bool { + return true +} + +func (a ConsentStrategy) SupportsDetachedCompletion() bool { + return false +} + +func (a ConsentStrategy) Guidance() agentauth.ConsentGuidance { + return agentauth.ConsentGuidance{ + FlowLabel: "local browser", + LoginInstructions: "For local authorization, run `tollbit auth login` in the same environment as the end user's browser. The CLI opens the browser when configured to do so, waits for the loopback callback, and completes authorization automatically.", + CompleteInstructions: "No detached completion command is used for the local browser flow; if authorization is interrupted, rerun `tollbit auth login`.", + CompleteArgsLabel: "not used (completion is automatic)", + Troubleshooting: []agentauth.ConsentGuidanceRow{ + {Symptom: "browser did not open", Meaning: "automatic browser launch failed or is disabled", Action: "open the printed URL manually in the local browser"}, + {Symptom: "authorization timed out", Meaning: "the loopback callback did not complete before the timeout", Action: "rerun `tollbit auth login`"}, + }, + } +} + +func (a ConsentStrategy) AuthorizeOBO(inv agentauth.Invocation, identity auth.AgentIdentity, baseToken agent.Token) (auth.AgentTokenResponse, error) { ctx := inv.Context() callback, err := loopback.Start(ctx, a.callbackAddress) @@ -66,11 +96,11 @@ func (a BrowserConsentAuthorizer) AuthorizeOBO(inv Invocation, identity auth.Age } defer callback.Close() - codeVerifier, codeChallenge, err := generatePKCE() + codeVerifier, codeChallenge, err := common.GeneratePKCE() if err != nil { return auth.AgentTokenResponse{}, err } - state, err := randomURLToken(32) + state, err := common.RandomURLToken(32) if err != nil { return auth.AgentTokenResponse{}, err } @@ -83,7 +113,7 @@ func (a BrowserConsentAuthorizer) AuthorizeOBO(inv Invocation, identity auth.Age RedirectURI: callback.RedirectURI, State: state, CodeChallenge: codeChallenge, - CodeChallengeMethod: pkceChallengeMethod, + CodeChallengeMethod: common.PKCEChallengeMethod, Scope: scope, }) if err != nil { @@ -96,7 +126,7 @@ func (a BrowserConsentAuthorizer) AuthorizeOBO(inv Invocation, identity auth.Age stdout := inv.OutOrStdout() fmt.Fprintf(stdout, "Authorize agent: %s\n\n", identity.Name) if a.autoOpenBrowser { - if err := openBrowser(startResp.ConsentURL); err != nil { + if err := a.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 your browser to continue:") } else { @@ -153,45 +183,6 @@ func (a BrowserConsentAuthorizer) AuthorizeOBO(inv Invocation, identity auth.Age return resp, nil } -func generatePKCE() (string, string, error) { - verifier, err := randomURLToken(48) - if err != nil { - return "", "", err - } - sum := sha256.Sum256([]byte(verifier)) - challenge := base64.RawURLEncoding.EncodeToString(sum[:]) - return verifier, challenge, nil -} - -func randomURLToken(size int) (string, error) { - b := make([]byte, size) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} - -func validateBrowserURL(rawURL string) error { - u, err := url.ParseRequestURI(rawURL) - if err != nil { - return err - } - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("refusing to open URL with scheme %q; only http and https are allowed", u.Scheme) - } - return nil -} - -func openBrowser(rawURL string) error { - if err := validateBrowserURL(rawURL); err != nil { - return err - } - switch runtime.GOOS { - case "darwin": - return exec.Command("open", rawURL).Start() - case "windows": - return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start() - default: - return exec.Command("xdg-open", rawURL).Start() - } +func (a ConsentStrategy) CompleteDetached(inv agentauth.Invocation, pending agentauth.PendingConsent, input agentauth.CompleteDetachedInput) (auth.AgentTokenResponse, error) { + return auth.AgentTokenResponse{}, errors.New("redirect consent does not support detached completion") } diff --git a/internal/agentauth/browser_consent_test.go b/internal/agentauth/redirect/redirect_test.go similarity index 63% rename from internal/agentauth/browser_consent_test.go rename to internal/agentauth/redirect/redirect_test.go index 05ff228..4432413 100644 --- a/internal/agentauth/browser_consent_test.go +++ b/internal/agentauth/redirect/redirect_test.go @@ -1,4 +1,4 @@ -package agentauth +package redirect import ( "bytes" @@ -15,10 +15,12 @@ import ( "github.com/golang-jwt/jwt/v5" "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 TestBrowserConsentAuthorizerAuthorizesOBO(t *testing.T) { +func TestConsentStrategyAuthorizesOBO(t *testing.T) { baseToken := testAgentJWT(t, nil) oboToken := testAgentJWT(t, &agent.OBOClaims{Ver: 1, Source: "consent", User: "usr_abc", Org: "org_xyz"}) callbackCh := make(chan string, 1) @@ -59,11 +61,14 @@ func TestBrowserConsentAuthorizerAuthorizesOBO(t *testing.T) { if r.Header.Get("Authorization") != "Bearer "+baseToken { t.Fatalf("unexpected redeem authorization: %q", r.Header.Get("Authorization")) } - var body auth.ConsentRedirectTokenRequest + var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatal(err) } - if body.AgentIdentifier != "agent-test" || body.Code != "code-123" || body.CodeVerifier == "" || body.RedirectURI == "" { + if body["grant_type"] == "consent" { + t.Fatal("bare consent grant_type is not accepted") + } + if body["grant_type"] != "consent:redirect" || body["agent_identifier"] != "agent-test" || body["code"] != "code-123" || body["code_verifier"] == "" || body["redirect_uri"] == "" { t.Fatalf("unexpected redeem body: %#v", body) } sawRedeem = true @@ -78,8 +83,9 @@ func TestBrowserConsentAuthorizerAuthorizesOBO(t *testing.T) { if err != nil { t.Fatal(err) } - authorizer, err := NewBrowserConsentAuthorizer(BrowserConsentAuthorizerConfig{ + authorizer, err := NewConsentStrategy(ConsentStrategyConfig{ AuthClient: authClient, + Runtime: newTestRuntime(t), CallbackAddress: "127.0.0.1:54321", AutoOpenBrowser: false, Timeout: 2 * time.Second, @@ -104,6 +110,9 @@ func TestBrowserConsentAuthorizerAuthorizesOBO(t *testing.T) { if !sawStart || !sawRedeem { t.Fatalf("expected start and redeem, sawStart=%v sawRedeem=%v", sawStart, sawRedeem) } + if authorizer.AutoOpensBrowser() { + t.Fatal("redirect should not auto-open when configured false") + } stdout := inv.stdout.String() for _, want := range []string{"Authorize agent: agent-test", "Open this URL in your browser", "Waiting for authorization"} { if !strings.Contains(stdout, want) { @@ -112,31 +121,99 @@ func TestBrowserConsentAuthorizerAuthorizesOBO(t *testing.T) { } } -func TestNewBrowserConsentAuthorizerValidatesConfig(t *testing.T) { +func TestConsentStrategyReportsAutoOpenBrowserConfig(t *testing.T) { + authorizer, err := NewConsentStrategy(ConsentStrategyConfig{ + AuthClient: newTestAuthClient(t), + Runtime: newTestRuntime(t), + CallbackAddress: "127.0.0.1:54321", + AutoOpenBrowser: true, + }) + if err != nil { + t.Fatal(err) + } + if !authorizer.AutoOpensBrowser() { + t.Fatal("expected redirect to report auto-open enabled") + } + if authorizer.Guidance().CompleteArgsLabel == "" { + t.Fatal("expected complete args guidance label") + } +} + +func TestConsentStrategyOmitsOfflineAccessWhenRefreshTokensDisabled(t *testing.T) { + baseToken := testAgentJWT(t, nil) + var sawStart bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/agent/v1/consent/redirect/start": + var body auth.ConsentRedirectStartRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Scope != "" { + t.Fatalf("expected empty scope, got %q", body.Scope) + } + sawStart = true + w.WriteHeader(http.StatusInternalServerError) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + authClient, err := auth.New(auth.ClientConfig{BaseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + authorizer, err := NewConsentStrategy(ConsentStrategyConfig{ + AuthClient: authClient, + Runtime: newTestRuntime(t), + CallbackAddress: "127.0.0.1:54321", + UseRefreshTokens: false, + }) + if err != nil { + t.Fatal(err) + } + + _, _ = authorizer.AuthorizeOBO(&testInvocation{}, auth.AgentIdentity{Name: "agent-test"}, agent.Token{RawToken: baseToken}) + if !sawStart { + t.Fatal("expected consent start request") + } +} + +func TestNewConsentStrategyValidatesConfig(t *testing.T) { authClient := newTestAuthClient(t) tests := []struct { name string - config BrowserConsentAuthorizerConfig + config ConsentStrategyConfig wantErr string }{ { name: "missing auth client", - config: BrowserConsentAuthorizerConfig{ + config: ConsentStrategyConfig{ CallbackAddress: "127.0.0.1:54321", }, wantErr: "auth client is required", }, { name: "missing callback address", - config: BrowserConsentAuthorizerConfig{ + config: ConsentStrategyConfig{ AuthClient: authClient, + Runtime: newTestRuntime(t), }, wantErr: "callback address is required", }, + { + name: "missing runtime", + config: ConsentStrategyConfig{ + AuthClient: authClient, + CallbackAddress: "127.0.0.1:54321", + }, + wantErr: "runtime is required", + }, { name: "negative timeout", - config: BrowserConsentAuthorizerConfig{ + config: ConsentStrategyConfig{ AuthClient: authClient, + Runtime: newTestRuntime(t), CallbackAddress: "127.0.0.1:54321", Timeout: -time.Second, }, @@ -146,7 +223,7 @@ func TestNewBrowserConsentAuthorizerValidatesConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := NewBrowserConsentAuthorizer(tt.config) + _, err := NewConsentStrategy(tt.config) if err == nil { t.Fatal("expected error") } @@ -208,24 +285,14 @@ func newTestAuthClient(t *testing.T) *auth.Client { return c } -func TestValidateBrowserURL(t *testing.T) { - t.Parallel() - - for _, rawURL := range []string{"http://x", "https://x"} { - if err := validateBrowserURL(rawURL); err != nil { - t.Fatalf("expected %q to be allowed, got %v", rawURL, err) - } - } - - for _, rawURL := range []string{ - "file:///etc/passwd", - "javascript:alert(1)", - "custom://x", - "", - "example.com/path", - } { - if err := validateBrowserURL(rawURL); err == nil { - t.Fatalf("expected %q to be rejected", rawURL) - } +func newTestRuntime(t *testing.T) *cliruntime.Runtime { + t.Helper() + rt, err := cliruntime.New(cliruntime.Config{ + ConfiguredEndUserProximity: configuration.RuntimeEndUserProximityLocal, + StateDir: t.TempDir(), + }) + if err != nil { + t.Fatal(err) } + return rt } diff --git a/internal/app/app.go b/internal/app/app.go index 1e57d00..80f2e5f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,12 +1,17 @@ package app import ( + "context" "fmt" "sync" "github.com/tollbit/cli/internal/agentauth" + "github.com/tollbit/cli/internal/agentauth/agentconfirmsicons" + "github.com/tollbit/cli/internal/agentauth/browserselecticon" + "github.com/tollbit/cli/internal/agentauth/redirect" "github.com/tollbit/cli/internal/client/auth" "github.com/tollbit/cli/internal/client/tollbit" + "github.com/tollbit/cli/internal/cliruntime" "github.com/tollbit/cli/internal/configuration" "github.com/tollbit/cli/internal/credentials/agenttoken" ) @@ -16,6 +21,7 @@ type Dependencies struct { Tollbit tollbit.Client OBOAuthorizer agentauth.OBOAuthorizer Credentials *agenttoken.CredentialManager + Runtime *cliruntime.Runtime } // App represents the application and its shared components. @@ -27,6 +33,7 @@ type App struct { tollbit func() (tollbit.Client, error) oboAuthorizer func() (agentauth.OBOAuthorizer, error) credentials func() (*agenttoken.CredentialManager, error) + runtime func() (*cliruntime.Runtime, error) } func New(config configuration.Config, opts ...Option) (*App, error) { @@ -42,6 +49,7 @@ func New(config configuration.Config, opts ...Option) (*App, error) { a.tollbit = sync.OnceValues(a.buildTollbit) a.oboAuthorizer = sync.OnceValues(a.buildOBOAuthorizer) a.credentials = sync.OnceValues(a.buildCredentials) + a.runtime = sync.OnceValues(a.buildRuntime) return a, nil } @@ -83,6 +91,24 @@ func (a *App) OBOAuthorizer() (agentauth.OBOAuthorizer, error) { return a.oboAuthorizer() } +func (a *App) Runtime() (*cliruntime.Runtime, error) { + return a.runtime() +} + +func (a *App) buildRuntime() (*cliruntime.Runtime, error) { + if a.deps.Runtime != nil { + return a.deps.Runtime, nil + } + rt, err := cliruntime.New(cliruntime.Config{ + ConfiguredEndUserProximity: a.config.Runtime.EndUserProximity, + StateDir: a.config.Runtime.StateDir, + }) + if err != nil { + return nil, fmt.Errorf("build cli runtime: %w", err) + } + return rt, nil +} + func (a *App) buildOBOAuthorizer() (agentauth.OBOAuthorizer, error) { if a.deps.OBOAuthorizer != nil { return a.deps.OBOAuthorizer, nil @@ -91,20 +117,104 @@ func (a *App) buildOBOAuthorizer() (agentauth.OBOAuthorizer, error) { if err != nil { return nil, err } - browserConsent := a.config.Auth.BrowserConsent - authorizer, err := agentauth.NewBrowserConsentAuthorizer(agentauth.BrowserConsentAuthorizerConfig{ - AuthClient: authClient, - CallbackAddress: browserConsent.CallbackAddress, - AutoOpenBrowser: browserConsent.AutoOpenBrowser, - Timeout: browserConsent.Timeout, - UseRefreshTokens: a.config.Auth.UseRefreshTokens, + strategyName, err := a.ResolveConsentStrategy(context.Background()) + if err != nil { + return nil, err + } + strategy, err := a.buildConsentStrategy(context.Background(), authClient, strategyName) + if err != nil { + return nil, err + } + + authorizer, err := agentauth.NewConfiguredOBOAuthorizer(agentauth.ConfiguredOBOAuthorizerConfig{ + Strategy: strategy, }) if err != nil { - return nil, fmt.Errorf("build OBO authorizer: %w", err) + return nil, fmt.Errorf("build configured obo authorizer: %w", err) } + return authorizer, nil } +func (a *App) ResolveConsentStrategy(ctx context.Context) (string, error) { + rt, err := a.Runtime() + if err != nil { + return "", err + } + proximity, err := rt.EndUserProximity(ctx) + if err != nil { + return "", err + } + switch proximity { + case cliruntime.EndUserProximityLocal: + return a.config.Auth.Consent.Strategy.Local, nil + case cliruntime.EndUserProximityRemote: + return a.config.Auth.Consent.Strategy.Remote, nil + default: + return "", fmt.Errorf("unsupported runtime end-user proximity %q", proximity) + } +} + +func (a *App) ConsentStrategy(method agentauth.ConsentMethod) (agentauth.ConsentStrategy, error) { + authClient, err := a.Auth() + if err != nil { + return nil, err + } + return a.buildConsentStrategy(context.Background(), authClient, string(method)) +} + +func (a *App) buildConsentStrategy(ctx context.Context, authClient *auth.Client, strategy string) (agentauth.ConsentStrategy, error) { + browserConsent := a.config.Auth.BrowserConsent + rt, err := a.Runtime() + if err != nil { + return nil, err + } + proximity, err := rt.EndUserProximity(ctx) + if err != nil { + return nil, err + } + autoOpenBrowser := browserConsent.AutoOpenBrowser && proximity == cliruntime.EndUserProximityLocal + switch strategy { + case configuration.ConsentStrategyRedirect: + redirectStrategy, err := redirect.NewConsentStrategy(redirect.ConsentStrategyConfig{ + AuthClient: authClient, + Runtime: rt, + CallbackAddress: browserConsent.CallbackAddress, + AutoOpenBrowser: autoOpenBrowser, + Timeout: browserConsent.Timeout, + UseRefreshTokens: a.config.Auth.UseRefreshTokens, + }) + if err != nil { + return nil, fmt.Errorf("build redirect consent strategy: %w", err) + } + return redirectStrategy, nil + case configuration.ConsentStrategyBrowserSelectIcon: + browserSelectIconStrategy, err := browserselecticon.NewConsentStrategy(browserselecticon.ConsentStrategyConfig{ + AuthClient: authClient, + Runtime: rt, + AutoOpenBrowser: autoOpenBrowser, + UseRefreshTokens: a.config.Auth.UseRefreshTokens, + }) + if err != nil { + return nil, fmt.Errorf("build browser-select-icon consent strategy: %w", err) + } + return browserSelectIconStrategy, nil + case configuration.ConsentStrategyAgentConfirmsIcons: + agentConfirmsIconsStrategy, err := agentconfirmsicons.NewConsentStrategy(agentconfirmsicons.ConsentStrategyConfig{ + AuthClient: authClient, + Runtime: rt, + AutoOpenBrowser: autoOpenBrowser, + UseRefreshTokens: a.config.Auth.UseRefreshTokens, + }) + if err != nil { + return nil, fmt.Errorf("build agent-confirms-icons consent strategy: %w", err) + } + return agentConfirmsIconsStrategy, nil + default: + return nil, fmt.Errorf("unsupported consent strategy %q", strategy) + } +} + func (a *App) Credentials() (*agenttoken.CredentialManager, error) { return a.credentials() } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 8f65adc..799200c 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -3,6 +3,7 @@ package app import ( "testing" + "github.com/tollbit/cli/internal/agentauth" "github.com/tollbit/cli/internal/configuration" ) @@ -30,14 +31,96 @@ func TestNewExposesConfigAndBuildsClients(t *testing.T) { } } +func TestNewBuildsBrowserSelectIconAuthorizer(t *testing.T) { + config := testConfig(t) + config.Runtime.EndUserProximity = configuration.RuntimeEndUserProximityRemote + config.Auth.BrowserConsent.AutoOpenBrowser = true + app, err := New(config) + if err != nil { + t.Fatal(err) + } + authorizer, err := app.OBOAuthorizer() + if err != nil { + t.Fatal(err) + } + if authorizer.SupportsOBORetry() { + t.Fatal("browser-select-icon authorizer should not support OBO retry") + } + strategy, err := app.ConsentStrategy("browser_select_icon") + if err != nil { + t.Fatal(err) + } + if strategy.AutoOpensBrowser() { + t.Fatal("remote browser-select-icon strategy should not auto-open browser") + } +} + +func TestConsentStrategyAutoOpenBrowserFollowsRuntimeAndConfig(t *testing.T) { + tests := []struct { + name string + endUserProximity string + autoOpenConfig bool + method agentauth.ConsentMethod + wantAutoOpenBrowser bool + }{ + { + name: "local redirect enabled", + endUserProximity: configuration.RuntimeEndUserProximityLocal, + autoOpenConfig: true, + method: agentauth.ConsentMethodRedirect, + wantAutoOpenBrowser: true, + }, + { + name: "local redirect disabled by config", + endUserProximity: configuration.RuntimeEndUserProximityLocal, + autoOpenConfig: false, + method: agentauth.ConsentMethodRedirect, + wantAutoOpenBrowser: false, + }, + { + name: "remote browser select icon disabled by runtime", + endUserProximity: configuration.RuntimeEndUserProximityRemote, + autoOpenConfig: true, + method: agentauth.ConsentMethodBrowserSelectIcon, + wantAutoOpenBrowser: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := testConfig(t) + config.Runtime.EndUserProximity = tt.endUserProximity + config.Auth.BrowserConsent.AutoOpenBrowser = tt.autoOpenConfig + app, err := New(config) + if err != nil { + t.Fatal(err) + } + strategy, err := app.ConsentStrategy(tt.method) + if err != nil { + t.Fatal(err) + } + if strategy.AutoOpensBrowser() != tt.wantAutoOpenBrowser { + t.Fatalf("expected AutoOpensBrowser=%v, got %v", tt.wantAutoOpenBrowser, strategy.AutoOpensBrowser()) + } + }) + } +} + func testConfig(t *testing.T) configuration.Config { t.Helper() return configuration.Config{ App: configuration.AppConfig{ Name: "test-cli", }, + Runtime: configuration.RuntimeConfig{EndUserProximity: configuration.RuntimeEndUserProximityLocal, StateDir: t.TempDir()}, Auth: configuration.AuthConfig{ BaseURL: "https://auth.example", + Consent: configuration.ConsentConfig{ + Strategy: configuration.ConsentStrategyConfig{ + Local: configuration.ConsentStrategyRedirect, + Remote: configuration.ConsentStrategyBrowserSelectIcon, + }, + }, BrowserConsent: configuration.BrowserConsentConfig{ CallbackAddress: "127.0.0.1:54321", Timeout: 0, @@ -49,3 +132,23 @@ func testConfig(t *testing.T) configuration.Config { Gateway: configuration.GatewayConfig{BaseURL: "https://gateway.example.com"}, } } + +func TestBuildConsentStrategyAgentConfirmsIcons(t *testing.T) { + config := testConfig(t) + config.Runtime.EndUserProximity = configuration.RuntimeEndUserProximityRemote + config.Auth.Consent.Strategy.Remote = configuration.ConsentStrategyAgentConfirmsIcons + app, err := New(config) + if err != nil { + t.Fatal(err) + } + strategy, err := app.ConsentStrategy(agentauth.ConsentMethodAgentConfirmsIcons) + if err != nil { + t.Fatal(err) + } + if strategy.Method() != agentauth.ConsentMethodAgentConfirmsIcons { + t.Fatalf("expected agent_confirms_icons method, got %q", strategy.Method()) + } + if strategy.Guidance().FlowLabel != "detached icon confirmation" { + t.Fatalf("unexpected guidance: %#v", strategy.Guidance()) + } +} diff --git a/internal/app/factory.go b/internal/app/factory.go index 9708ae0..1474056 100644 --- a/internal/app/factory.go +++ b/internal/app/factory.go @@ -9,9 +9,6 @@ type Option func(*options) type Factory struct { Config configuration.Config Options []Option - // SkipPinEndpoints disables release endpoint pinning. For tests that - // inject mock auth/gateway base URLs only; production must leave this false. - SkipPinEndpoints bool } func (f Factory) New(overrides configuration.OverrideOptions) (*App, error) { @@ -19,12 +16,6 @@ func (f Factory) New(overrides configuration.OverrideOptions) (*App, error) { if err != nil { return nil, err } - if !f.SkipPinEndpoints { - config, err = configuration.PinEndpoints(config) - if err != nil { - return nil, err - } - } return New(config, f.Options...) } diff --git a/internal/cli/auth.go b/internal/cli/auth.go index c08bd99..04b95be 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -7,9 +7,13 @@ import ( "time" "github.com/spf13/cobra" + "github.com/tollbit/cli/internal/agentauth" "github.com/tollbit/cli/internal/app" "github.com/tollbit/cli/internal/cli/globalflags" + "github.com/tollbit/cli/internal/cliruntime" + "github.com/tollbit/cli/internal/configuration" "github.com/tollbit/cli/internal/credentials/agenttoken" + "github.com/tollbit/cli/internal/errorsx/problemjson" "github.com/tollbit/cli/internal/tokens/agent" ) @@ -43,13 +47,14 @@ func NewAuthCommand(factory app.Factory) *cobra.Command { Long: "Manages your agent's profile and authorization token.", Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return UsageError("auth requires login, logout, status, or set") + return UsageError("auth requires login, complete, logout, status, or set") } return UsageError("unknown auth command %q", args[0]) }, } cmd.AddCommand( NewAuthLoginCommand(factory), + NewAuthCompleteCommand(factory), NewAuthLogoutCommand(factory), NewAuthStatusCommand(factory), NewAuthSetCommand(factory), @@ -57,11 +62,30 @@ func NewAuthCommand(factory app.Factory) *cobra.Command { return cmd } +func NewAuthCompleteCommand(factory app.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "complete", + Short: "Complete a pending detached agent authorization", + Long: authCompleteLongHelp(factory), + Args: func(cmd *cobra.Command, args []string) error { + if len(args) > 3 { + return UsageError("auth complete accepts at most 3 icon name arguments") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runAuthComplete(cmd, factory, args) + }, + } + return cmd +} + func NewAuthLoginCommand(factory app.Factory) *cobra.Command { var opts authLoginOptions cmd := &cobra.Command{ Use: "login", Short: "Authorize this agent with a Tollbit user and organization", + Long: authLoginLongHelp(factory), Args: func(cmd *cobra.Command, args []string) error { if len(args) != 0 { return UsageError("auth login does not accept arguments") @@ -78,6 +102,49 @@ func NewAuthLoginCommand(factory app.Factory) *cobra.Command { return cmd } +func authCompleteLongHelp(factory app.Factory) string { + preamble := strings.TrimSpace(`Complete a pending detached agent authorization. + +Use this after auth login starts a detached authorization flow. The command +checks once and exits. If authorization is still pending, the pending auth record +is kept so the command can be run again after the end user completes the required +browser steps. If the challenge is denied, expired, or invalid, the pending auth +record is cleared. If authorization is still pending, this exits with code 3.`) + local, localOK := configuredStrategyGuidance(factory, factory.Config.Auth.Consent.Strategy.Local) + remote, remoteOK := configuredStrategyGuidance(factory, factory.Config.Auth.Consent.Strategy.Remote) + if !localOK || !remoteOK { + return preamble + "\n\nRun `tollbit guide` for flow instructions." + } + return strings.TrimSpace(fmt.Sprintf("%s\n\nConfigured local completion flow (%s):\n%s\n\nConfigured remote completion flow (%s):\n%s", preamble, local.FlowLabel, local.CompleteInstructions, remote.FlowLabel, remote.CompleteInstructions)) +} + +func authLoginLongHelp(factory app.Factory) string { + preamble := strings.TrimSpace(`Authorize this agent with a Tollbit user and organization. + +End-user proximity describes where the agent environment running this CLI is +located relative to the end user's browser. The CLI uses that context to present +either the local or the detached (remote) flow. When a detached flow starts, this +command exits with code 3 and keeps the pending authorization.`) + local, localOK := configuredStrategyGuidance(factory, factory.Config.Auth.Consent.Strategy.Local) + remote, remoteOK := configuredStrategyGuidance(factory, factory.Config.Auth.Consent.Strategy.Remote) + if !localOK || !remoteOK { + return preamble + "\n\nRun `tollbit guide` for flow instructions." + } + return strings.TrimSpace(fmt.Sprintf("%s\n\nConfigured local login flow (%s):\n%s\n\nConfigured remote login flow (%s):\n%s", preamble, local.FlowLabel, local.LoginInstructions, remote.FlowLabel, remote.LoginInstructions)) +} + +func configuredStrategyGuidance(factory app.Factory, strategyName string) (agentauth.ConsentGuidance, bool) { + application, err := factory.New(configuration.OverrideOptions{}) + if err != nil { + return agentauth.ConsentGuidance{}, false + } + strategy, err := application.ConsentStrategy(agentauth.ConsentMethod(strategyName)) + if err != nil { + return agentauth.ConsentGuidance{}, false + } + return strategy.Guidance(), true +} + func NewAuthLogoutCommand(factory app.Factory) *cobra.Command { var opts authLogoutOptions cmd := &cobra.Command{ @@ -161,9 +228,16 @@ func runAuthLogin(cmd *cobra.Command, factory app.Factory, opts authLoginOptions if err != nil { return RuntimeError(err) } + if err := printAuthLoginRuntimeContext(cmd, app); err != nil { + return RuntimeError(err) + } token, err := credentials.GetAgentToken(cmd, identity, agenttoken.WithOBO(), agenttoken.WithRefreshTokens(opts.useRefreshTokens)) if err != nil { + if isAuthorizationPending(err) { + fmt.Fprintln(cmd.OutOrStdout(), "Authorization pending.") + return ExitError{Code: ExitCodeAuthorizationPending, Err: errors.New("authorization pending")} + } return RuntimeError(err) } if err := credentials.WriteIdentity(ctx, identity); err != nil { @@ -174,9 +248,12 @@ func runAuthLogin(cmd *cobra.Command, factory app.Factory, opts authLoginOptions if err != nil { return RuntimeError(err) } + fmt.Fprintln(cmd.ErrOrStderr(), authorizedMessage(identity.Name, claims)) + return nil +} - stderr := cmd.ErrOrStderr() - msg := fmt.Sprintf("authorized as %s", identity.Name) +func authorizedMessage(name string, claims agent.Claims) string { + msg := fmt.Sprintf("authorized as %s", name) if claims.OBO != nil { parts := make([]string, 0, 2) if claims.OBO.User != "" { @@ -189,10 +266,134 @@ func runAuthLogin(cmd *cobra.Command, factory app.Factory, opts authLoginOptions msg += " (on behalf of " + strings.Join(parts, " / ") + ")" } } - fmt.Fprintln(stderr, msg) + return msg +} + +func printAuthLoginRuntimeContext(cmd *cobra.Command, application *app.App) error { + ctx := cmd.Context() + rt, err := application.Runtime() + if err != nil { + return err + } + status, err := rt.Status(ctx) + if err != nil { + return err + } + strategyName, err := application.ResolveConsentStrategy(ctx) + if err != nil { + return err + } + strategy, err := application.ConsentStrategy(agentauth.ConsentMethod(strategyName)) + if err != nil { + return err + } + stdout := cmd.OutOrStdout() + fmt.Fprintf(stdout, "Runtime end-user proximity: %s (%s)\n", status.EndUserProximity, runtimeEndUserProximitySourceLabel(status.EndUserProximitySource)) + fmt.Fprintf(stdout, "Authorization flow: %s\n", strategy.Guidance().FlowLabel) return nil } +func runtimeEndUserProximitySourceLabel(source cliruntime.EndUserProximitySource) string { + switch source { + case cliruntime.EndUserProximitySourceConfigured: + return "configured" + case cliruntime.EndUserProximitySourceSavedRuntimeState: + return "saved runtime state" + case cliruntime.EndUserProximitySourceAutoDetect: + return "auto-detect" + default: + return string(source) + } +} + +func runAuthComplete(cmd *cobra.Command, factory app.Factory, args []string) error { + application, err := appForCommand(factory, cmd) + if err != nil { + return RuntimeError(err) + } + ctx := cmd.Context() + credentials, err := application.Credentials() + if err != nil { + return RuntimeError(err) + } + pending, exists, err := credentials.GetPendingConsent(ctx) + if err != nil { + return RuntimeError(err) + } + if !exists { + return RuntimeError(errors.New("no pending authorization found")) + } + if pending.Method == agentauth.ConsentMethodAgentConfirmsIcons { + if len(args) == 0 { + return UsageError("auth complete requires icon names for this authorization flow; run `tollbit auth complete `. Valid icon names: %s", strings.Join(pending.IconNames, " ")) + } + } else if len(args) != 0 { + return UsageError("auth complete does not accept arguments for this authorization flow") + } + strategy, err := application.ConsentStrategy(pending.Method) + if err != nil { + return RuntimeError(err) + } + if !strategy.SupportsDetachedCompletion() { + return RuntimeError(errors.New("auth complete is not supported or required for this authorization flow; run auth login instead")) + } + resp, err := strategy.CompleteDetached(cmd, pending, agentauth.CompleteDetachedInput{IconNames: args}) + if err != nil { + if isAuthorizationPending(err) { + return ExitError{Code: ExitCodeAuthorizationPending, Err: errors.New("authorization still pending")} + } + if isUnrecognizedIcon(err) { + if len(pending.IconNames) > 0 { + return RuntimeError(fmt.Errorf("%w\nValid icon names: %s", err, strings.Join(pending.IconNames, " "))) + } + return RuntimeError(err) + } + if isPendingConsentInvalidated(err) { + if clearErr := credentials.ClearPendingConsent(ctx); clearErr != nil { + return RuntimeError(fmt.Errorf("clear pending authorization after invalidation: %w", clearErr)) + } + } + return RuntimeError(err) + } + token, err := credentials.CompletePendingConsent(ctx, pending, resp) + if err != nil { + return RuntimeError(err) + } + claims, err := token.Claims() + if err != nil { + return RuntimeError(err) + } + fmt.Fprintln(cmd.ErrOrStderr(), authorizedMessage(pending.AgentIdentity.Name, claims)) + return nil +} + +func isUnrecognizedIcon(err error) bool { + var problem problemjson.Problem + return errors.As(err, &problem) && problem.Code != nil && *problem.Code == problemjson.ErrorCodeUnrecognizedIcon +} + +func isAuthorizationPending(err error) bool { + var pending agentauth.AuthorizationPendingError + if errors.As(err, &pending) { + return true + } + var problem problemjson.Problem + return errors.As(err, &problem) && problem.Code != nil && *problem.Code == problemjson.ErrorCodeAuthorizationPending +} + +func isPendingConsentInvalidated(err error) bool { + var problem problemjson.Problem + if !errors.As(err, &problem) || problem.Code == nil { + return false + } + switch *problem.Code { + case problemjson.ErrorCodeAccessDenied, problemjson.ErrorCodeExpiredToken, problemjson.ErrorCodeInvalidGrant: + return true + default: + return false + } +} + func runAuthLogout(cmd *cobra.Command, factory app.Factory, opts authLogoutOptions) error { app, err := appForCommand(factory, cmd) if err != nil { @@ -258,12 +459,22 @@ func runAuthStatus(cmd *cobra.Command, factory app.Factory, opts authStatusOptio return nil } + pending, pendingExists, err := credentials.GetPendingConsent(ctx) + if err != nil { + return RuntimeError(err) + } + refreshStatus := credentials.RefreshTokenStatus(ctx) + autoRefresh := credentials.AutoRefreshEnabled() + status := map[string]any{ "identity": map[string]string{ "name": identity.Name, "user_agent": identity.UserAgent, }, - "token": agenttoken.Status(token, tokenExists, tokenErr), + "auto_refresh": autoRefresh, + "pending_authorization": pendingAuthorizationStatus(pending, pendingExists), + "refresh_token": refreshStatus, + "token": agenttoken.Status(token, tokenExists, tokenErr), } if opts.asJSON { return RuntimeError(writeJSON(cmd.OutOrStdout(), status)) @@ -278,6 +489,8 @@ func runAuthStatus(cmd *cobra.Command, factory app.Factory, opts authStatusOptio fmt.Fprintf(stdout, "User agent:\n") } printAuthTokenStatus(stdout, token, tokenExists, tokenErr) + printRefreshTokenStatus(stdout, refreshStatus, autoRefresh) + printPendingAuthorizationStatus(stdout, pending, pendingExists) if tokenExists && tokenErr == nil { if claims, claimsErr := token.Claims(); claimsErr == nil && claims.Subject != "" && claims.Subject != identity.Name { fmt.Fprintf(stderr, "token subject %q does not match profile name %q — run 'tollbit auth login'\n", claims.Subject, identity.Name) @@ -286,6 +499,62 @@ func runAuthStatus(cmd *cobra.Command, factory app.Factory, opts authStatusOptio return nil } +func pendingAuthorizationStatus(pending agentauth.PendingConsent, exists bool) map[string]any { + status := map[string]any{"pending": exists} + if !exists { + return status + } + status["challenge_id"] = pending.ChallengeID + status["identity"] = map[string]string{ + "name": pending.AgentIdentity.Name, + "user_agent": pending.AgentIdentity.UserAgent, + } + return status +} + +func printRefreshTokenStatus(w interface{ Write([]byte) (int, error) }, status agenttoken.RefreshTokenStatus, autoRefresh bool) { + fmt.Fprintf(w, "Auto-refresh: %s\n", enabledLabel(autoRefresh)) + if status.Error != "" { + if status.Present { + fmt.Fprintf(w, "Refresh: invalid (%s)\n", status.Error) + return + } + fmt.Fprintf(w, "Refresh: absent (%s)\n", status.Error) + return + } + if !status.Present { + fmt.Fprintln(w, "Refresh: absent") + return + } + state := "present" + if status.Expired { + state = "expired" + } + if status.ExpiresAt != "" { + fmt.Fprintf(w, "Refresh: %s (expires %s)\n", state, status.ExpiresAt) + return + } + fmt.Fprintf(w, "Refresh: %s\n", state) +} + +func enabledLabel(enabled bool) string { + if enabled { + return "enabled" + } + return "disabled" +} + +func printPendingAuthorizationStatus(w interface{ Write([]byte) (int, error) }, pending agentauth.PendingConsent, exists bool) { + if !exists { + return + } + fmt.Fprintln(w, "Pending: authorization pending (complete in browser, then run 'tollbit auth complete')") + fmt.Fprintf(w, "Pending agent: %s\n", pending.AgentIdentity.Name) + if pending.AgentIdentity.UserAgent != "" { + fmt.Fprintf(w, "Pending user agent: %s\n", pending.AgentIdentity.UserAgent) + } +} + func runAuthSet(cmd *cobra.Command, factory app.Factory, opts authSetOptions) error { if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("user-agent") { return UsageError("auth set requires --name and/or --user-agent") diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ae51915..01387d9 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "github.com/tollbit/cli/internal/agentauth" "github.com/tollbit/cli/internal/app" "github.com/tollbit/cli/internal/client/auth" "github.com/tollbit/cli/internal/client/tollbit" @@ -29,7 +30,7 @@ const testAgentDefaultNameEnvVar = "TOLLBIT_AGENT_DEFAULT_NAME" const testAgentDefaultUserAgentEnvVar = "TOLLBIT_AGENT_DEFAULT_USER_AGENT" func executeTestCommand(args []string, stdin io.Reader, stdout, stderr *bytes.Buffer) int { - cmd := NewCommandTree(app.Factory{Config: testConfig(), SkipPinEndpoints: true}) + cmd := NewCommandTree(app.Factory{Config: testConfig()}) cmd.SetArgs(args) cmd.SetIn(stdin) cmd.SetOut(stdout) @@ -63,9 +64,16 @@ func testConfig() configuration.Config { App: configuration.AppConfig{ Name: "tollbit", }, + Runtime: configuration.RuntimeConfig{EndUserProximity: configuration.RuntimeEndUserProximityLocal, StateDir: storageDir}, Auth: configuration.AuthConfig{ BaseURL: authBaseURL, UseRefreshTokens: true, + Consent: configuration.ConsentConfig{ + Strategy: configuration.ConsentStrategyConfig{ + Local: configuration.ConsentStrategyRedirect, + Remote: configuration.ConsentStrategyBrowserSelectIcon, + }, + }, BrowserConsent: configuration.BrowserConsentConfig{ CallbackAddress: "127.0.0.1:54321", Timeout: 3 * time.Minute, @@ -248,7 +256,7 @@ func TestRunAuthLoginStatusAndLogout(t *testing.T) { switch body["grant_type"] { case "self_attested": _ = json.NewEncoder(w).Encode(map[string]string{"token": baseToken}) - case "consent": + case "consent:redirect": if body["agent_identifier"] != "agent-test" || body["code"] != "auth-code" || body["code_verifier"] == "" || body["redirect_uri"] == "" { t.Fatalf("unexpected redeem body: %#v", body) } @@ -582,13 +590,13 @@ func TestRunGuideInstallWritesSkillUnderSkillName(t *testing.T) { if err != nil { t.Fatalf("read installed skill at %s: %v", wantPath, err) } - skillPath := filepath.Join("..", "..", "skill", "tollbit-cli", "SKILL.md") - want, err := os.ReadFile(skillPath) - if err != nil { - t.Fatalf("read source skill file: %v", err) + for _, want := range []string{"Configured remote flow: detached browser relay", "relay the printed URL and verification icon", "Auth instructions rendered for local=redirect, remote=browser_select_icon"} { + if !strings.Contains(string(got), want) { + t.Fatalf("expected installed skill to contain %q, got %q", want, string(got)) + } } - if string(got) != string(want) { - t.Fatal("installed skill differs from embedded markdown") + if strings.Contains(string(got), "{{") { + t.Fatalf("installed skill contains unrendered template syntax: %q", string(got)) } } @@ -660,12 +668,583 @@ func TestRunGuideOutputsEmbeddedSkill(t *testing.T) { t.Fatalf("expected empty stderr, got %q", stderr.String()) } - skillPath := filepath.Join("..", "..", "skill", "tollbit-cli", "SKILL.md") - want, err := os.ReadFile(skillPath) + for _, want := range []string{"Configured remote flow: detached browser relay", "relay the printed URL and verification icon", "`auth complete` takes: pending auth", "Auth instructions rendered for local=redirect, remote=browser_select_icon"} { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("expected guide output to contain %q, got %q", want, stdout.String()) + } + } + if strings.Contains(stdout.String(), "{{") { + t.Fatalf("guide output contains unrendered template syntax: %q", stdout.String()) + } +} + +func TestRunGuideRendersAgentConfirmsIconsCompleteInputs(t *testing.T) { + config := testConfig() + config.Auth.Consent.Strategy.Remote = configuration.ConsentStrategyAgentConfirmsIcons + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, []string{"guide"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got %d stderr=%q", code, stderr.String()) + } + for _, want := range []string{"Configured remote flow: detached icon confirmation", "relay only the printed consent URL", "`auth complete` takes: pending auth plus three icon names"} { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("expected guide output to contain %q, got %q", want, stdout.String()) + } + } +} + +func executeTestCommandWithConfig(config configuration.Config, args []string, stdin io.Reader, stdout, stderr *bytes.Buffer) int { + cmd := NewCommandTree(app.Factory{Config: config}) + cmd.SetArgs(args) + cmd.SetIn(stdin) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + err := cmd.Execute() + if err != nil { + fmt.Fprintln(stderr, err) + } + return ExitCode(err) +} + +func TestRuntimeSetAndStatus(t *testing.T) { + storageDir := t.TempDir() + runtimeDir := t.TempDir() + config := testConfig() + config.Credentials.StorageDir = storageDir + config.Runtime.StateDir = runtimeDir + + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, []string{"runtime", "set", "--end-user-proximity", "remote"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("runtime set failed: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "saved runtime end-user proximity remote") { + t.Fatalf("unexpected runtime set stdout: %q", stdout.String()) + } + info, err := os.Stat(filepath.Join(runtimeDir, "runtime.json")) if err != nil { - t.Fatalf("read skill file: %v", err) + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("expected runtime state mode 0600, got %#o", got) + } + + stdout.Reset() + stderr.Reset() + code = executeTestCommandWithConfig(config, []string{"runtime", "status"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("runtime status failed: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + for _, want := range []string{ + "Runtime state dir: " + runtimeDir, + "Runtime state saved: true", + "Credentials dir: " + storageDir, + "End-user proximity: local (source: configured)", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("expected runtime status to contain %q, got %q", want, stdout.String()) + } + } + + stdout.Reset() + stderr.Reset() + code = executeTestCommandWithConfig(config, []string{"--end-user-proximity", "auto-detect", "runtime", "status", "--json"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("runtime json status failed: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + var status map[string]any + if err := json.Unmarshal(stdout.Bytes(), &status); err != nil { + t.Fatalf("failed to decode status json: %v\n%s", err, stdout.String()) + } + if status["configured_end_user_proximity"] != "auto-detect" || status["saved_end_user_proximity"] != "remote" || status["end_user_proximity"] != "remote" || status["end_user_proximity_source"] != "saved_runtime_state" || status["state_dir"] != runtimeDir || status["state_exists"] != true || status["credentials_dir"] != storageDir { + t.Fatalf("unexpected runtime status: %#v", status) + } +} + +func TestRuntimeSetRejectsAutoDetect(t *testing.T) { + t.Setenv(testCredentialsStorageDirEnvVar, t.TempDir()) + var stdout, stderr bytes.Buffer + + code := executeTestCommand([]string{"runtime", "set", "--end-user-proximity", "auto-detect"}, nil, &stdout, &stderr) + + if code != 2 { + t.Fatalf("expected usage error, got code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "runtime set --end-user-proximity must be local or remote") { + t.Fatalf("unexpected stderr: %q", stderr.String()) + } +} + +func TestRunAuthStatusShowsPendingAuthorization(t *testing.T) { + storageDir := t.TempDir() + t.Setenv(testCredentialsStorageDirEnvVar, storageDir) + pending := agentauth.PendingConsent{ + Method: agentauth.ConsentMethodBrowserSelectIcon, + ChallengeID: "ach_test", + AgentIdentity: auth.AgentIdentity{ + Name: "pending-agent", + UserAgent: "pending-agent/0.1", + }, + BaseToken: testAgentJWT(t), + CodeVerifier: "verifier-1", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339), + } + pendingJSON, err := json.Marshal(pending) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(storageDir, "pending-auth.json"), pendingJSON, 0o600); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := executeTestCommand([]string{"auth", "status"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("auth status failed: code=%d stderr=%q", code, stderr.String()) + } + for _, want := range []string{ + "Agent: anonymous", + "Token: none", + "Auto-refresh: enabled", + "Refresh: absent", + "Pending: authorization pending (complete in browser, then run 'tollbit auth complete')", + "Pending agent: pending-agent", + "Pending user agent: pending-agent/0.1", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("expected status stdout to contain %q, got %q", want, stdout.String()) + } + } + if strings.Contains(stdout.String(), "browser_select_icon") { + t.Fatalf("expected status stdout not to expose consent method, got %q", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + code = executeTestCommand([]string{"auth", "status", "--json"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("auth json status failed: code=%d stderr=%q", code, stderr.String()) + } + var status map[string]any + if err := json.Unmarshal(stdout.Bytes(), &status); err != nil { + t.Fatalf("failed to decode status json: %v\n%s", err, stdout.String()) + } + identity := status["identity"].(map[string]any) + if identity["name"] != "anonymous" { + t.Fatalf("expected active identity to remain anonymous, got %#v", identity) + } + pendingStatus := status["pending_authorization"].(map[string]any) + if pendingStatus["pending"] != true || pendingStatus["challenge_id"] != "ach_test" { + t.Fatalf("unexpected pending authorization status: %#v", pendingStatus) + } + if _, ok := pendingStatus["method"]; ok { + t.Fatalf("expected pending authorization json not to expose method: %#v", pendingStatus) + } + pendingIdentity := pendingStatus["identity"].(map[string]any) + if pendingIdentity["name"] != "pending-agent" || pendingIdentity["user_agent"] != "pending-agent/0.1" { + t.Fatalf("unexpected pending identity status: %#v", pendingIdentity) + } + if status["auto_refresh"] != true { + t.Fatalf("expected auto_refresh true, got %#v", status) + } + refreshStatus := status["refresh_token"].(map[string]any) + if refreshStatus["present"] != false { + t.Fatalf("unexpected refresh token status: %#v", refreshStatus) + } + + stdout.Reset() + stderr.Reset() + code = executeTestCommand([]string{"auth", "status", "--check"}, nil, &stdout, &stderr) + if code != 2 { + t.Fatalf("expected check to fail until token exists, got code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestRunAuthLoginRemotePendingUsesRetryExitCode(t *testing.T) { + storageDir := t.TempDir() + baseToken := testAgentJWT(t) + var sawStart bool + + authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/agent/v1/tokens/identity": + if r.Method != http.MethodPost { + t.Fatalf("expected POST token, got %s", r.Method) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["grant_type"] != "self_attested" { + t.Fatalf("unexpected token grant body: %#v", body) + } + _ = json.NewEncoder(w).Encode(map[string]string{"token": baseToken}) + case "/agent/v1/consent/browser-select-icon/start": + if r.Method != http.MethodPost { + t.Fatalf("expected POST start, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer "+baseToken { + t.Fatalf("unexpected start authorization: %q", r.Header.Get("Authorization")) + } + var body auth.ConsentBrowserSelectIconStartRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.CodeChallenge == "" || body.CodeChallengeMethod != "S256" { + t.Fatalf("unexpected start body: %#v", body) + } + sawStart = true + _ = json.NewEncoder(w).Encode(auth.ConsentBrowserSelectIconStartResponse{ + ChallengeID: "ach_pending", + ConsentURL: "https://auth.example.test/oauth/consent/browser-select-icon?consent_challenge=ach_pending", + ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339), + CorrectIcon: auth.AgentConsentIcon{Name: "SNAIL", Art: "@_"}, + }) + default: + t.Fatalf("unexpected auth request: %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer authSrv.Close() + + config := testConfig() + config.Auth.BaseURL = authSrv.URL + config.Runtime.StateDir = storageDir + config.Credentials.StorageDir = storageDir + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, []string{"--end-user-proximity", "remote", "auth", "login", "--name", "agent-test"}, nil, &stdout, &stderr) + if code != ExitCodeAuthorizationPending { + t.Fatalf("expected pending exit code %d, got %d stdout=%q stderr=%q", ExitCodeAuthorizationPending, code, stdout.String(), stderr.String()) + } + if !sawStart { + t.Fatal("expected browser-select-icon start request") + } + for _, want := range []string{"Runtime end-user proximity: remote (configured)", "Authorization flow: detached browser relay", "Open this URL in the end user's browser", "BEGIN VERIFICATION ICON", "Authorization pending."} { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("expected login stdout to contain %q, got %q", want, stdout.String()) + } + } + if _, err := os.Stat(filepath.Join(storageDir, "pending-auth.json")); err != nil { + t.Fatalf("expected pending auth saved: %v", err) + } +} + +func TestRunAuthCompleteRejectedWithoutPendingAuth(t *testing.T) { + t.Setenv(testCredentialsStorageDirEnvVar, t.TempDir()) + var stdout, stderr bytes.Buffer + + code := executeTestCommand([]string{"auth", "complete"}, nil, &stdout, &stderr) + + if code != 1 { + t.Fatalf("expected exit code 1, got %d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "no pending authorization found") { + t.Fatalf("expected no pending authorization error, got stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +func TestRunAuthCompletePendingUsesRetryExitCode(t *testing.T) { + storageDir := t.TempDir() + baseToken := testAgentJWT(t) + var sawRedeem bool + authSrv := 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 auth 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 map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["grant_type"] == "consent" { + t.Fatal("bare consent grant_type is not accepted") + } + if body["grant_type"] != "consent:browser_select_icon" || body["challenge_id"] != "ach_pending" || body["code_verifier"] != "verifier-1" { + t.Fatalf("unexpected redeem body: %#v", body) + } + sawRedeem = true + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "https://errors.tollbit.com/authorization-pending", + "title": "Authorization pending", + "status": http.StatusBadRequest, + "code": "authorization_pending", + }) + })) + defer authSrv.Close() + config := testConfig() + config.Auth.BaseURL = authSrv.URL + config.Runtime.StateDir = storageDir + config.Credentials.StorageDir = storageDir + pending := agentauth.PendingConsent{ + Method: agentauth.ConsentMethodBrowserSelectIcon, + ChallengeID: "ach_pending", + AgentIdentity: auth.AgentIdentity{ + Name: "pending-agent", + }, + BaseToken: baseToken, + CodeVerifier: "verifier-1", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339), + } + pendingJSON, err := json.Marshal(pending) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(storageDir, "pending-auth.json"), pendingJSON, 0o600); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + + code := executeTestCommandWithConfig(config, []string{"--end-user-proximity", "remote", "auth", "complete"}, nil, &stdout, &stderr) + + if code != ExitCodeAuthorizationPending { + t.Fatalf("expected pending exit code %d, got %d stdout=%q stderr=%q", ExitCodeAuthorizationPending, code, stdout.String(), stderr.String()) + } + if !sawRedeem { + t.Fatal("expected auth complete to check pending authorization") + } + if !strings.Contains(stderr.String(), "authorization still pending") { + t.Fatalf("expected pending message, got stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + if _, err := os.Stat(filepath.Join(storageDir, "pending-auth.json")); err != nil { + t.Fatalf("expected pending authorization to be preserved: %v", err) + } +} + +func writePendingConsentForTest(t *testing.T, storageDir string, pending agentauth.PendingConsent) { + t.Helper() + pendingJSON, err := json.Marshal(pending) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(storageDir, "pending-auth.json"), pendingJSON, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestRunAuthLoginRemoteAgentConfirmsIconsPendingUsesRetryExitCode(t *testing.T) { + storageDir := t.TempDir() + baseToken := testAgentJWT(t) + var sawStart bool + + authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/agent/v1/tokens/identity": + _ = json.NewEncoder(w).Encode(map[string]string{"token": baseToken}) + case "/agent/v1/consent/agent-confirms-icons/start": + if r.Header.Get("Authorization") != "Bearer "+baseToken { + t.Fatalf("unexpected start 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 start body: %#v", body) + } + sawStart = true + _ = json.NewEncoder(w).Encode(auth.ConsentAgentConfirmsIconsStartResponse{ + ChallengeID: "ach_pending", + ConsentURL: "https://auth.example.test/oauth/consent/agent-confirms-icons?consent_challenge=ach_pending", + ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339), + IconNames: []string{"ANCHOR", "FOX", "STAR"}, + }) + default: + t.Fatalf("unexpected auth request: %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer authSrv.Close() + + config := testConfig() + config.Auth.BaseURL = authSrv.URL + config.Runtime.StateDir = storageDir + config.Credentials.StorageDir = storageDir + config.Auth.Consent.Strategy.Remote = configuration.ConsentStrategyAgentConfirmsIcons + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, []string{"--end-user-proximity", "remote", "auth", "login", "--name", "agent-test"}, nil, &stdout, &stderr) + if code != ExitCodeAuthorizationPending { + t.Fatalf("expected pending exit code %d, got %d stdout=%q stderr=%q", ExitCodeAuthorizationPending, code, stdout.String(), stderr.String()) + } + if !sawStart { + t.Fatal("expected agent-confirms-icons start request") + } + for _, want := range []string{"Authorization flow: detached icon confirmation", "Relay ONLY this URL", "VALID ICON NAMES", "ANCHOR FOX STAR", "tollbit auth complete "} { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("expected login stdout to contain %q, got %q", want, stdout.String()) + } + } + pendingRaw, err := os.ReadFile(filepath.Join(storageDir, "pending-auth.json")) + if err != nil { + t.Fatalf("expected pending auth saved: %v", err) + } + var pending agentauth.PendingConsent + if err := json.Unmarshal(pendingRaw, &pending); err != nil { + t.Fatal(err) + } + if len(pending.IconNames) != 3 || pending.IconNames[0] != "ANCHOR" || pending.IconNames[1] != "FOX" || pending.IconNames[2] != "STAR" { + t.Fatalf("expected pending auth to contain icon names, got %#v", pending.IconNames) + } +} + +func TestRunAuthCompleteAgentConfirmsIconsSucceedsWithIconNames(t *testing.T) { + storageDir := t.TempDir() + baseToken := testAgentJWT(t) + successToken := testAgentJWTWithOBO(t) + var sawRedeem bool + authSrv := 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 auth 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, ok := body["icon_names"].([]any) + if !ok || len(icons) != 3 || icons[0] != "FOX" || icons[1] != "ANCHOR" || icons[2] != "STAR" { + t.Fatalf("unexpected icon_names: %#v", body["icon_names"]) + } + if body["grant_type"] != "consent:agent_confirms_icons" || body["challenge_id"] != "ach_pending" || body["code_verifier"] != "verifier-1" { + t.Fatalf("unexpected redeem body: %#v", body) + } + sawRedeem = true + _ = json.NewEncoder(w).Encode(map[string]string{"token": successToken}) + })) + defer authSrv.Close() + + config := testConfig() + config.Auth.BaseURL = authSrv.URL + config.Runtime.StateDir = storageDir + config.Credentials.StorageDir = storageDir + config.Auth.Consent.Strategy.Remote = configuration.ConsentStrategyAgentConfirmsIcons + writePendingConsentForTest(t, storageDir, agentauth.PendingConsent{ + Method: agentauth.ConsentMethodAgentConfirmsIcons, + ChallengeID: "ach_pending", + AgentIdentity: auth.AgentIdentity{Name: "pending-agent"}, + BaseToken: baseToken, + CodeVerifier: "verifier-1", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339), + IconNames: []string{"ANCHOR", "FOX", "STAR"}, + }) + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, []string{"--end-user-proximity", "remote", "auth", "complete", "fox,", "Anchor", " STAR "}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected success, got code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !sawRedeem { + t.Fatal("expected redeem request") + } + if !strings.Contains(stderr.String(), "authorized as pending-agent") { + t.Fatalf("expected authorized message, got stderr=%q", stderr.String()) + } + if _, err := os.Stat(filepath.Join(storageDir, "pending-auth.json")); !os.IsNotExist(err) { + t.Fatalf("expected pending auth cleared, stat err=%v", err) + } +} + +func TestRunAuthCompleteAgentConfirmsIconsUnrecognizedIconKeepsPending(t *testing.T) { + storageDir := t.TempDir() + baseToken := testAgentJWT(t) + authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "https://errors.tollbit.com/unrecognized-icon", + "title": "Unrecognized icon", + "status": http.StatusBadRequest, + "detail": "unrecognized icon: FXX", + "code": "unrecognized_icon", + }) + })) + defer authSrv.Close() + + config := testConfig() + config.Auth.BaseURL = authSrv.URL + config.Runtime.StateDir = storageDir + config.Credentials.StorageDir = storageDir + config.Auth.Consent.Strategy.Remote = configuration.ConsentStrategyAgentConfirmsIcons + writePendingConsentForTest(t, storageDir, agentauth.PendingConsent{ + Method: agentauth.ConsentMethodAgentConfirmsIcons, + ChallengeID: "ach_pending", + AgentIdentity: auth.AgentIdentity{Name: "pending-agent"}, + BaseToken: baseToken, + CodeVerifier: "verifier-1", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339), + IconNames: []string{"ANCHOR", "FOX", "STAR"}, + }) + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, []string{"--end-user-proximity", "remote", "auth", "complete", "fxx", "anchor", "star"}, nil, &stdout, &stderr) + if code != 1 { + t.Fatalf("expected exit code 1, got %d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "unrecognized icon: FXX") || !strings.Contains(stderr.String(), "Valid icon names: ANCHOR FOX STAR") { + t.Fatalf("expected unrecognized icon guidance, got stderr=%q", stderr.String()) + } + if _, err := os.Stat(filepath.Join(storageDir, "pending-auth.json")); err != nil { + t.Fatalf("expected pending authorization to be preserved: %v", err) + } +} + +func TestRunAuthCompleteHelpRendersBothConfiguredFlows(t *testing.T) { + tests := []struct { + name string + remoteStrategy string + want []string + }{ + { + name: "browser select icon", + remoteStrategy: configuration.ConsentStrategyBrowserSelectIcon, + want: []string{ + "Configured local completion flow (local browser):", + "No detached completion command is used for the local browser flow", + "Configured remote completion flow (detached browser relay):", + "run `tollbit auth complete` with no arguments", + }, + }, + { + name: "agent confirms icons", + remoteStrategy: configuration.ConsentStrategyAgentConfirmsIcons, + want: []string{ + "Configured local completion flow (local browser):", + "No detached completion command is used for the local browser flow", + "Configured remote completion flow (detached icon confirmation):", + "tollbit auth complete ", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := testConfig() + config.Auth.Consent.Strategy.Remote = tt.remoteStrategy + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, []string{"auth", "complete", "--help"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got %d stderr=%q", code, stderr.String()) + } + for _, want := range tt.want { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("expected help to contain %q, got %q", want, stdout.String()) + } + } + }) + } +} + +func TestRunAuthHelpFallsBackOnBrokenConfig(t *testing.T) { + config := testConfig() + config.Auth.Consent.Strategy.Remote = "unknown" + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, []string{"auth", "complete", "--help"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got %d stderr=%q", code, stderr.String()) } - if stdout.String() != string(want) { - t.Fatalf("guide output differs from embedded skill markdown") + if !strings.Contains(stdout.String(), "Run `tollbit guide` for flow instructions.") { + t.Fatalf("expected fallback guide pointer, got %q", stdout.String()) } } diff --git a/internal/cli/errors.go b/internal/cli/errors.go index b2a299a..ffbe9e6 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -9,6 +9,10 @@ import ( "github.com/tollbit/cli/internal/installmethod" ) +// ExitCodeAuthorizationPending signals that a detached authorization flow was +// started or is still awaiting end-user browser consent. +const ExitCodeAuthorizationPending = 3 + type ExitError struct { Code int Err error diff --git a/internal/cli/global_flags_dev_test.go b/internal/cli/global_flags_dev_test.go new file mode 100644 index 0000000..bdb9e60 --- /dev/null +++ b/internal/cli/global_flags_dev_test.go @@ -0,0 +1,22 @@ +//go:build dev + +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestDevHelpOmitsDisabledGlobalConfigFlags(t *testing.T) { + var stdout, stderr bytes.Buffer + code := executeTestCommand([]string{"--help"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got %d stderr=%q", code, stderr.String()) + } + for _, flag := range []string{"--auth-base-url", "--gateway-base-url", "--auth-browser-consent-callback-address", "--auth-use-refresh-tokens", "--credentials-storage-dir"} { + if strings.Contains(stdout.String(), flag) { + t.Fatalf("expected dev help to omit %s, got %q", flag, stdout.String()) + } + } +} diff --git a/internal/cli/global_flags_release_test.go b/internal/cli/global_flags_release_test.go new file mode 100644 index 0000000..acb9cc6 --- /dev/null +++ b/internal/cli/global_flags_release_test.go @@ -0,0 +1,22 @@ +//go:build !dev + +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestReleaseHelpOmitsDisabledGlobalConfigFlags(t *testing.T) { + var stdout, stderr bytes.Buffer + code := executeTestCommand([]string{"--help"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got %d stderr=%q", code, stderr.String()) + } + for _, flag := range []string{"--auth-base-url", "--gateway-base-url", "--auth-browser-consent-callback-address", "--auth-use-refresh-tokens", "--credentials-storage-dir"} { + if strings.Contains(stdout.String(), flag) { + t.Fatalf("expected release help to omit %s, got %q", flag, stdout.String()) + } + } +} diff --git a/internal/cli/globalflags/config.go b/internal/cli/globalflags/config.go index 88847dc..ac9e71e 100644 --- a/internal/cli/globalflags/config.go +++ b/internal/cli/globalflags/config.go @@ -2,7 +2,6 @@ package globalflags import ( "fmt" - "os" "strings" "time" @@ -11,52 +10,159 @@ import ( "github.com/tollbit/cli/internal/configuration" ) -const ShowDevFlagsEnvVar = "TOLLBIT_SHOW_DEV_FLAGS" +type ( + configFlagOptions struct { + authBaseURL string + gatewayBaseURL string + endUserProximity string + runtimeStateDir string + authRetryOnOBORequired bool + authTokenTTLSeconds int32 + authUseRefreshTokens bool + authBrowserConsentCallbackAddress string + authBrowserConsentTimeout time.Duration + authBrowserConsentAutoOpenBrowser bool + credentialsStorageDir string + } -const ( - FlagAuthBaseURL = "auth-base-url" - FlagAuthRetryOnOBORequired = "auth-retry-on-obo-required" - FlagAuthTokenTTLSeconds = "auth-token-ttl-seconds" - FlagAuthUseRefreshTokens = "auth-use-refresh-tokens" - FlagAuthBrowserConsentCallbackAddress = "auth-browser-consent-callback-address" - FlagAuthBrowserConsentTimeout = "auth-browser-consent-timeout" - FlagAuthBrowserConsentAutoOpenBrowser = "auth-browser-consent-auto-open-browser" - FlagCredentialsStorageDir = "credentials-storage-dir" - FlagGatewayBaseURL = "gateway-base-url" + configFlag struct { + name string + path string + usage string + takesValue bool + hideDefault bool + add func(*pflag.FlagSet, *configFlagOptions, configFlag) + apply func(*pflag.FlagSet, *configuration.OverrideOptions, configFlag) error + } ) -var devFlagNames = []string{ - FlagAuthBaseURL, - FlagGatewayBaseURL, - FlagAuthRetryOnOBORequired, - FlagAuthTokenTTLSeconds, - FlagAuthUseRefreshTokens, - FlagAuthBrowserConsentCallbackAddress, - FlagAuthBrowserConsentTimeout, - FlagAuthBrowserConsentAutoOpenBrowser, - FlagCredentialsStorageDir, -} - -type configFlagOptions struct { - authBaseURL string - gatewayBaseURL string - authRetryOnOBORequired bool - authTokenTTLSeconds int32 - authUseRefreshTokens bool - authBrowserConsentCallbackAddress string - authBrowserConsentTimeout time.Duration - authBrowserConsentAutoOpenBrowser bool - credentialsStorageDir string -} - -type flagValue interface { - ~bool | ~int32 | time.Duration -} +var ( + configFlags = []configFlag{ + stringFlag( + "end-user-proximity", + "runtime.end_user_proximity", + "override where the CLI runs relative to the end user: local or remote", + func(opts *configFlagOptions) string { return opts.endUserProximity }, + func(overrides *configuration.OverrideOptions, value *string) { + overrides.RuntimeEndUserProximity = value + }, + true, + ), + // stringFlag( + // "runtime-state-dir", + // "runtime.state_dir", + // "runtime state directory; __default__ uses the platform default", + // func(opts *configFlagOptions) string { return opts.runtimeStateDir }, + // func(overrides *configuration.OverrideOptions, value *string) { + // overrides.RuntimeStateDir = value + // }, + // false, + // ), + // Removing from active flags for now + // boolFlag( + // "auth-retry-on-obo-required", + // "auth.retry_on_obo_required", + // "retry once with OBO authorization when required", + // func(opts *configFlagOptions) bool { return opts.authRetryOnOBORequired }, + // func(overrides *configuration.OverrideOptions, value *bool) { + // overrides.AuthRetryOnOBORequired = value + // }, + // ), + // Removing from active flags for now + // int32Flag( + // "auth-token-ttl-seconds", + // "auth.token_ttl_seconds", + // "agent token TTL in seconds; 0 uses server default", + // func(opts *configFlagOptions) int32 { return opts.authTokenTTLSeconds }, + // func(overrides *configuration.OverrideOptions, value *int32) { + // overrides.AuthTokenTTLSeconds = value + // }, + // ), + // Removing from active flags for now + // boolFlag( + // "auth-use-refresh-tokens", + // "auth.use_refresh_tokens", + // "request and use refresh tokens for OBO authorization", + // func(opts *configFlagOptions) bool { return opts.authUseRefreshTokens }, + // func(overrides *configuration.OverrideOptions, value *bool) { + // overrides.AuthUseRefreshTokens = value + // }, + // ), + // Removing from active flags for now + // durationFlag( + // "auth-browser-consent-timeout", + // "auth.browser_consent.timeout", + // "auth browser consent timeout", + // func(opts *configFlagOptions) time.Duration { return opts.authBrowserConsentTimeout }, + // func(overrides *configuration.OverrideOptions, value *time.Duration) { + // overrides.AuthBrowserConsentTimeout = value + // }, + // ), + // Removing from active flags for now + // boolFlag( + // "auth-browser-consent-auto-open-browser", + // "auth.browser_consent.auto_open_browser", + // "automatically open browser for auth consent", + // func(opts *configFlagOptions) bool { return opts.authBrowserConsentAutoOpenBrowser }, + // func(overrides *configuration.OverrideOptions, value *bool) { + // overrides.AuthBrowserConsentAutoOpenBrowser = value + // }, + // ), + // Removing from active flags for now + // stringFlag( + // "credentials-storage-dir", + // "credentials.storage_dir", + // "credential storage directory; __default__ uses the platform default", + // func(opts *configFlagOptions) string { return opts.credentialsStorageDir }, + // func(overrides *configuration.OverrideOptions, value *string) { + // overrides.CredentialsStorageDir = value + // }, + // false, + // ), + // Removing from active flags for now + // stringFlag( + // "auth-base-url", + // "auth.base_url", + // "Auth API base URL", + // func(opts *configFlagOptions) string { return opts.authBaseURL }, + // func(overrides *configuration.OverrideOptions, value *string) { + // overrides.AuthBaseURL = value + // }, + // false, + // ), + // Removing from active flags for now + // stringFlag( + // "gateway-base-url", + // "gateway.base_url", + // "Gateway API base URL", + // func(opts *configFlagOptions) string { return opts.gatewayBaseURL }, + // func(overrides *configuration.OverrideOptions, value *string) { + // overrides.GatewayBaseURL = value + // }, + // false, + // ), + // Removing from active flags for now + // stringFlag( + // "auth-browser-consent-callback-address", + // "auth.browser_consent.callback_address", + // "auth browser consent callback address", + // func(opts *configFlagOptions) string { + // return opts.authBrowserConsentCallbackAddress + // }, + // func(overrides *configuration.OverrideOptions, value *string) { + // overrides.AuthBrowserConsentCallbackAddress = value + // }, + // false, + // ), + } +) -func Add(cmd *cobra.Command, config configuration.Config) { - opts := &configFlagOptions{ +func newConfigFlagOptions(config configuration.Config) *configFlagOptions { + return &configFlagOptions{ authBaseURL: config.Auth.BaseURL, gatewayBaseURL: config.Gateway.BaseURL, + endUserProximity: config.Runtime.EndUserProximity, + runtimeStateDir: config.Runtime.StateDir, authRetryOnOBORequired: config.Auth.RetryOnOBORequired, authTokenTTLSeconds: config.Auth.TokenTTLSeconds, authUseRefreshTokens: config.Auth.UseRefreshTokens, @@ -65,89 +171,131 @@ func Add(cmd *cobra.Command, config configuration.Config) { authBrowserConsentAutoOpenBrowser: config.Auth.BrowserConsent.AutoOpenBrowser, credentialsStorageDir: config.Credentials.StorageDir, } - flags := cmd.PersistentFlags() - flags.StringVar(&opts.authBaseURL, FlagAuthBaseURL, opts.authBaseURL, "Auth API base URL") - flags.StringVar(&opts.gatewayBaseURL, FlagGatewayBaseURL, opts.gatewayBaseURL, "Gateway API base URL") - flags.BoolVar(&opts.authRetryOnOBORequired, FlagAuthRetryOnOBORequired, opts.authRetryOnOBORequired, "retry once with OBO authorization when required") - flags.Int32Var(&opts.authTokenTTLSeconds, FlagAuthTokenTTLSeconds, opts.authTokenTTLSeconds, "agent token TTL in seconds; 0 uses server default") - flags.BoolVar(&opts.authUseRefreshTokens, FlagAuthUseRefreshTokens, opts.authUseRefreshTokens, "request and use refresh tokens for OBO authorization") - flags.StringVar(&opts.authBrowserConsentCallbackAddress, FlagAuthBrowserConsentCallbackAddress, opts.authBrowserConsentCallbackAddress, "auth browser consent callback address") - flags.DurationVar(&opts.authBrowserConsentTimeout, FlagAuthBrowserConsentTimeout, opts.authBrowserConsentTimeout, "auth browser consent timeout") - flags.BoolVar(&opts.authBrowserConsentAutoOpenBrowser, FlagAuthBrowserConsentAutoOpenBrowser, opts.authBrowserConsentAutoOpenBrowser, "automatically open browser for auth consent") - flags.StringVar(&opts.credentialsStorageDir, FlagCredentialsStorageDir, opts.credentialsStorageDir, "credential storage directory; __default__ uses the platform default") - if !DevFlagsVisible() { - for _, name := range devFlagNames { - if flag := flags.Lookup(name); flag != nil { - flag.Hidden = true +} + +func stringFlag(name string, path string, usage string, defaultValue func(*configFlagOptions) string, setOverride func(*configuration.OverrideOptions, *string), hideDefault bool) configFlag { + return configFlag{ + name: name, + path: path, + usage: usage, + takesValue: true, + hideDefault: hideDefault, + add: func(flags *pflag.FlagSet, opts *configFlagOptions, flag configFlag) { + value := defaultValue(opts) + flags.StringVar(&value, flag.name, value, flag.usage) + }, + apply: func(flags *pflag.FlagSet, overrides *configuration.OverrideOptions, flag configFlag) error { + value, err := changedStr(flags, flag.name) + if err != nil { + return err } - } + setOverride(overrides, value) + return nil + }, } } -func DevFlagsVisible() bool { - value := strings.TrimSpace(os.Getenv(ShowDevFlagsEnvVar)) - switch strings.ToLower(value) { - case "", "0", "false", "no", "off": - return false - default: - return true +func boolFlag(name string, path string, usage string, defaultValue func(*configFlagOptions) bool, setOverride func(*configuration.OverrideOptions, *bool)) configFlag { + return configFlag{ + name: name, + path: path, + usage: usage, + takesValue: false, + add: func(flags *pflag.FlagSet, opts *configFlagOptions, flag configFlag) { + value := defaultValue(opts) + flags.BoolVar(&value, flag.name, value, flag.usage) + }, + apply: func(flags *pflag.FlagSet, overrides *configuration.OverrideOptions, flag configFlag) error { + if !flags.Changed(flag.name) { + setOverride(overrides, nil) + return nil + } + value, err := flags.GetBool(flag.name) + if err != nil { + return fmt.Errorf("read %s: %w", flag.name, err) + } + setOverride(overrides, &value) + return nil + }, + } +} + +func int32Flag(name string, path string, usage string, defaultValue func(*configFlagOptions) int32, setOverride func(*configuration.OverrideOptions, *int32)) configFlag { + return configFlag{ + name: name, + path: path, + usage: usage, + takesValue: true, + add: func(flags *pflag.FlagSet, opts *configFlagOptions, flag configFlag) { + value := defaultValue(opts) + flags.Int32Var(&value, flag.name, value, flag.usage) + }, + apply: func(flags *pflag.FlagSet, overrides *configuration.OverrideOptions, flag configFlag) error { + if !flags.Changed(flag.name) { + setOverride(overrides, nil) + return nil + } + value, err := flags.GetInt32(flag.name) + if err != nil { + return fmt.Errorf("read %s: %w", flag.name, err) + } + setOverride(overrides, &value) + return nil + }, + } +} + +func durationFlag(name string, path string, usage string, defaultValue func(*configFlagOptions) time.Duration, setOverride func(*configuration.OverrideOptions, *time.Duration)) configFlag { + return configFlag{ + name: name, + path: path, + usage: usage, + takesValue: true, + add: func(flags *pflag.FlagSet, opts *configFlagOptions, flag configFlag) { + value := defaultValue(opts) + flags.DurationVar(&value, flag.name, value, flag.usage) + }, + apply: func(flags *pflag.FlagSet, overrides *configuration.OverrideOptions, flag configFlag) error { + if !flags.Changed(flag.name) { + setOverride(overrides, nil) + return nil + } + value, err := flags.GetDuration(flag.name) + if err != nil { + return fmt.Errorf("read %s: %w", flag.name, err) + } + setOverride(overrides, &value) + return nil + }, } } func OverridesFromCommand(cmd *cobra.Command) (configuration.OverrideOptions, error) { var overrides configuration.OverrideOptions flags := cmd.Root().PersistentFlags() - var err error - - overrides.AuthBaseURL, err = changedStr(flags, FlagAuthBaseURL) - if err != nil { - return overrides, err - } - overrides.GatewayBaseURL, err = changedStr(flags, FlagGatewayBaseURL) - if err != nil { - return overrides, err - } - overrides.AuthRetryOnOBORequired, err = changedValue(flags, FlagAuthRetryOnOBORequired, flags.GetBool) - if err != nil { - return overrides, err - } - overrides.AuthTokenTTLSeconds, err = changedValue(flags, FlagAuthTokenTTLSeconds, flags.GetInt32) - if err != nil { - return overrides, err - } - overrides.AuthUseRefreshTokens, err = changedValue(flags, FlagAuthUseRefreshTokens, flags.GetBool) - if err != nil { - return overrides, err - } - overrides.AuthBrowserConsentCallbackAddress, err = changedStr(flags, FlagAuthBrowserConsentCallbackAddress) - if err != nil { - return overrides, err - } - overrides.AuthBrowserConsentTimeout, err = changedValue(flags, FlagAuthBrowserConsentTimeout, flags.GetDuration) - if err != nil { - return overrides, err - } - overrides.AuthBrowserConsentAutoOpenBrowser, err = changedValue(flags, FlagAuthBrowserConsentAutoOpenBrowser, flags.GetBool) - if err != nil { - return overrides, err - } - overrides.CredentialsStorageDir, err = changedStr(flags, FlagCredentialsStorageDir) - if err != nil { - return overrides, err + for _, flag := range configFlags { + if !configuration.IsConfigurable(flag.path) { + continue + } + if err := flag.apply(flags, &overrides, flag); err != nil { + return overrides, err + } } - return overrides, nil } -func changedValue[T flagValue](flags *pflag.FlagSet, name string, get func(string) (T, error)) (*T, error) { - if !flags.Changed(name) { - return nil, nil - } - value, err := get(name) - if err != nil { - return nil, fmt.Errorf("read %s: %w", name, err) +func Add(cmd *cobra.Command, config configuration.Config) { + opts := newConfigFlagOptions(config) + flags := cmd.PersistentFlags() + for _, flag := range configFlags { + if !configuration.IsConfigurable(flag.path) { + continue + } + flag.add(flags, opts, flag) + if flag.hideDefault { + flags.Lookup(flag.name).DefValue = "" + } } - return &value, nil } func changedStr(flags *pflag.FlagSet, name string) (*string, error) { diff --git a/internal/cli/globalflags/config_test.go b/internal/cli/globalflags/config_test.go index 94b3a4a..bd584e4 100644 --- a/internal/cli/globalflags/config_test.go +++ b/internal/cli/globalflags/config_test.go @@ -1,81 +1,35 @@ package globalflags import ( - "bytes" - "strings" "testing" "github.com/spf13/cobra" "github.com/tollbit/cli/internal/configuration" ) -func TestDevFlagsHiddenByDefault(t *testing.T) { - t.Setenv(ShowDevFlagsEnvVar, "") - - cmd := newTestRootCommand(t) - var out bytes.Buffer - cmd.SetOut(&out) - if err := cmd.Help(); err != nil { - t.Fatalf("help: %v", err) - } - - help := out.String() - for _, name := range devFlagNames { - if strings.Contains(help, name) { - t.Fatalf("expected dev flag %q hidden from help, got:\n%s", name, help) - } - } -} - -func TestDevFlagsVisibleWhenEnabled(t *testing.T) { - t.Setenv(ShowDevFlagsEnvVar, "1") - +func TestAddRegistersOnlyConfigurableFlags(t *testing.T) { cmd := newTestRootCommand(t) - for _, name := range devFlagNames { - flag := cmd.PersistentFlags().Lookup(name) - if flag == nil { - t.Fatalf("missing dev flag %q", name) + for _, flag := range configFlags { + if !configuration.IsConfigurable(flag.path) { + if cmd.PersistentFlags().Lookup(flag.name) != nil { + t.Fatalf("expected non-configurable flag %q to be unregistered", flag.name) + } + continue } - if flag.Hidden { - t.Fatalf("expected dev flag %q to be visible", name) + if cmd.PersistentFlags().Lookup(flag.name) == nil { + t.Fatalf("expected configurable flag %q to be registered", flag.name) } } } -func TestDevFlagsStillAcceptedWhenHidden(t *testing.T) { - t.Setenv(ShowDevFlagsEnvVar, "") - +func TestOverridesFromCommandEmptyWithoutChanges(t *testing.T) { cmd := newTestRootCommand(t) - if err := cmd.ParseFlags([]string{"--gateway-base-url", "https://gateway.example.com"}); err != nil { - t.Fatalf("parse flags: %v", err) - } - if !cmd.PersistentFlags().Changed(FlagGatewayBaseURL) { - t.Fatal("expected hidden dev flag to be parsed") + overrides, err := OverridesFromCommand(cmd) + if err != nil { + t.Fatal(err) } -} - -func TestDevFlagsVisible(t *testing.T) { - tests := []struct { - name string - value string - want bool - }{ - {name: "unset", value: "", want: false}, - {name: "zero", value: "0", want: false}, - {name: "false", value: "false", want: false}, - {name: "no", value: "no", want: false}, - {name: "off", value: "off", want: false}, - {name: "one", value: "1", want: true}, - {name: "true", value: "true", want: true}, - {name: "yes", value: "yes", want: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Setenv(ShowDevFlagsEnvVar, tt.value) - if got := DevFlagsVisible(); got != tt.want { - t.Fatalf("DevFlagsVisible() = %v, want %v", got, tt.want) - } - }) + if overrides != (configuration.OverrideOptions{}) { + t.Fatalf("expected empty overrides, got %#v", overrides) } } diff --git a/internal/cli/guide.go b/internal/cli/guide.go index 1030b70..1a4c429 100644 --- a/internal/cli/guide.go +++ b/internal/cli/guide.go @@ -1,19 +1,24 @@ package cli import ( + "bytes" "fmt" "io" "os" "path/filepath" + "strings" + "text/template" "github.com/spf13/cobra" + "github.com/tollbit/cli/internal/agentauth" + "github.com/tollbit/cli/internal/app" ) type guideOptions struct { installPath string } -func NewGuideCommand() *cobra.Command { +func NewGuideCommand(factory app.Factory) *cobra.Command { var opts guideOptions cmd := &cobra.Command{ Use: "guide", @@ -25,29 +30,105 @@ func NewGuideCommand() *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - return runGuide(cmd, opts) + return runGuide(cmd, factory, opts) }, } cmd.Flags().StringVar(&opts.installPath, "install", "", "install guide to //SKILL.md") return cmd } -func runGuide(cmd *cobra.Command, opts guideOptions) error { +func runGuide(cmd *cobra.Command, factory app.Factory, opts guideOptions) error { + markdown, err := renderCLIGuideMarkdown(cmd, factory, skillMarkdown) + if err != nil { + return RuntimeError(err) + } if opts.installPath == "" { - fmt.Fprint(cmd.OutOrStdout(), skillMarkdown) + fmt.Fprint(cmd.OutOrStdout(), markdown) return nil } - skillName, err := skillNameFromMarkdown(skillMarkdown) + skillName, err := skillNameFromMarkdown(markdown) if err != nil { return RuntimeError(err) } destDir := resolveSkillInstallDirectory(opts.installPath, skillName) target := filepath.Join(destDir, "SKILL.md") - return writeGuideFile(target, cmd.OutOrStdout(), cmd.ErrOrStderr()) + return writeGuideFile(target, markdown, cmd.OutOrStdout(), cmd.ErrOrStderr()) +} + +func renderCLIGuideMarkdown(cmd *cobra.Command, factory app.Factory, markdown string) (string, error) { + application, err := appForCommand(factory, cmd) + if err != nil { + return "", err + } + local, err := application.ConsentStrategy(agentauth.ConsentMethod(factory.Config.Auth.Consent.Strategy.Local)) + if err != nil { + return "", err + } + remote, err := application.ConsentStrategy(agentauth.ConsentMethod(factory.Config.Auth.Consent.Strategy.Remote)) + if err != nil { + return "", err + } + data := cliGuideTemplateData{ + AuthInstructions: renderAuthInstructions(local.Guidance(), remote.Guidance()), + AuthCompleteInputs: completeInputs(local, remote), + AuthRenderedFooter: fmt.Sprintf("Auth instructions rendered for local=%s, remote=%s; re-run `tollbit guide --install ` if the CLI's auth configuration changes.", local.Method(), remote.Method()), + } + tmpl, err := template.New("tollbit-cli-skill").Parse(markdown) + if err != nil { + return "", err + } + var out bytes.Buffer + if err := tmpl.Execute(&out, data); err != nil { + return "", err + } + return out.String(), nil +} + +type cliGuideTemplateData struct { + AuthInstructions string + AuthCompleteInputs string + AuthRenderedFooter string +} + +func renderAuthInstructions(local, remote agentauth.ConsentGuidance) string { + var b strings.Builder + fmt.Fprintf(&b, "Configured local flow: %s\n\n", local.FlowLabel) + fmt.Fprintf(&b, "- %s\n", local.LoginInstructions) + fmt.Fprintf(&b, "- %s\n\n", local.CompleteInstructions) + fmt.Fprintf(&b, "Configured remote flow: %s\n\n", remote.FlowLabel) + fmt.Fprintf(&b, "- %s\n", remote.LoginInstructions) + fmt.Fprintf(&b, "- %s", remote.CompleteInstructions) + if len(remote.Troubleshooting) > 0 { + fmt.Fprintln(&b) + fmt.Fprintln(&b) + fmt.Fprintln(&b, "Remote troubleshooting:") + for _, row := range remote.Troubleshooting { + fmt.Fprintf(&b, "- `%s`: %s; %s.\n", row.Symptom, row.Meaning, row.Action) + } + } + return strings.TrimSpace(b.String()) +} + +func completeInputs(local, remote agentauth.ConsentStrategy) string { + labels := make([]string, 0, 2) + if local.SupportsDetachedCompletion() { + labels = append(labels, "local: "+local.Guidance().CompleteArgsLabel) + } + if remote.SupportsDetachedCompletion() { + label := remote.Guidance().CompleteArgsLabel + if len(labels) > 0 { + label = "remote: " + label + } + labels = append(labels, label) + } + if len(labels) == 0 { + return "not used" + } + return strings.Join(labels, " / ") } -func writeGuideFile(path string, stdout, stderr io.Writer) error { +func writeGuideFile(path string, markdown string, stdout, stderr io.Writer) error { existed := false if _, err := os.Stat(path); err == nil { existed = true @@ -58,7 +139,7 @@ func writeGuideFile(path string, stdout, stderr io.Writer) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return RuntimeError(fmt.Errorf("create directory for %s: %v", path, err)) } - if err := os.WriteFile(path, []byte(skillMarkdown), 0o644); err != nil { + if err := os.WriteFile(path, []byte(markdown), 0o644); err != nil { return RuntimeError(fmt.Errorf("write %s: %v", path, err)) } diff --git a/internal/cli/index.go b/internal/cli/index.go index 0148840..8397911 100644 --- a/internal/cli/index.go +++ b/internal/cli/index.go @@ -11,7 +11,8 @@ func NewCommandTree(factory app.Factory) *cobra.Command { NewAuthCommand(factory), NewContentCommand(factory), NewSearchCommand(factory), - NewGuideCommand(), + NewRuntimeCommand(factory), + NewGuideCommand(factory), NewVersionCommand(), ) return rootCmd diff --git a/internal/cli/runtime.go b/internal/cli/runtime.go new file mode 100644 index 0000000..d49afd5 --- /dev/null +++ b/internal/cli/runtime.go @@ -0,0 +1,212 @@ +package cli + +import ( + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/tollbit/cli/internal/app" + "github.com/tollbit/cli/internal/cliruntime" +) + +type ( + runtimeStatusOptions struct { + asJSON bool + } + + runtimeSetOptions struct { + endUserProximity string + } +) + +func NewRuntimeCommand(factory app.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "runtime", + Short: "Manage CLI runtime state", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return UsageError("runtime requires status, set, or help") + } + return UsageError("unknown runtime command %q", args[0]) + }, + } + cmd.AddCommand( + NewRuntimeStatusCommand(factory), + NewRuntimeSetCommand(factory), + NewRuntimeHelpCommand(), + ) + return cmd +} + +func NewRuntimeStatusCommand(factory app.Factory) *cobra.Command { + var opts runtimeStatusOptions + cmd := &cobra.Command{ + Use: "status", + Short: "Show CLI runtime state", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return UsageError("runtime status does not accept arguments") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runRuntimeStatus(cmd, factory, opts) + }, + } + cmd.Flags().BoolVar(&opts.asJSON, "json", false, "print runtime status as JSON") + return cmd +} + +func NewRuntimeSetCommand(factory app.Factory) *cobra.Command { + var opts runtimeSetOptions + cmd := &cobra.Command{ + Use: "set", + Short: "Set CLI runtime state", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return UsageError("runtime set does not accept arguments") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runRuntimeSet(cmd, factory, opts) + }, + } + cmd.Flags().StringVar(&opts.endUserProximity, "end-user-proximity", "", "runtime end-user proximity to persist: local or remote") + return cmd +} + +func NewRuntimeHelpCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "help", + Short: "Explain CLI runtime end-user proximity", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return UsageError("runtime help does not accept arguments") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Fprint(cmd.OutOrStdout(), runtimeHelpText) + return nil + }, + } + return cmd +} + +func runRuntimeStatus(cmd *cobra.Command, factory app.Factory, opts runtimeStatusOptions) error { + application, err := appForCommand(factory, cmd) + if err != nil { + return RuntimeError(err) + } + rt, err := application.Runtime() + if err != nil { + return RuntimeError(err) + } + status, err := rt.Status(cmd.Context()) + if err != nil { + return RuntimeError(err) + } + if opts.asJSON { + out := map[string]any{ + "configured_end_user_proximity": status.ConfiguredEndUserProximity, + "end_user_proximity_source": status.EndUserProximitySource, + "end_user_proximity": status.EndUserProximity, + "state_dir": status.StateDir, + "state_path": status.StatePath, + "state_exists": status.StateExists, + "credentials_dir": application.Config().Credentials.StorageDir, + } + if status.StateExists { + out["saved_end_user_proximity"] = status.SavedEndUserProximity + } + return RuntimeError(writeJSON(cmd.OutOrStdout(), out)) + } + stdout := cmd.OutOrStdout() + fmt.Fprintf(stdout, "Runtime state dir: %s\n", status.StateDir) + fmt.Fprintf(stdout, "Runtime state saved: %t\n", status.StateExists) + fmt.Fprintf(stdout, "Credentials dir: %s\n", application.Config().Credentials.StorageDir) + fmt.Fprintf(stdout, "End-user proximity: %s (source: %s)\n", status.EndUserProximity, runtimeEndUserProximitySourceLabel(status.EndUserProximitySource)) + return nil +} + +func runRuntimeSet(cmd *cobra.Command, factory app.Factory, opts runtimeSetOptions) error { + if !cmd.Flags().Changed("end-user-proximity") { + return UsageError("runtime set requires --end-user-proximity") + } + proximity, err := parseRuntimeSetEndUserProximity(opts.endUserProximity) + if err != nil { + return UsageError("%s", err.Error()) + } + application, err := appForCommand(factory, cmd) + if err != nil { + return RuntimeError(err) + } + rt, err := application.Runtime() + if err != nil { + return RuntimeError(err) + } + if err := rt.SetEndUserProximity(cmd.Context(), proximity); err != nil { + return RuntimeError(err) + } + fmt.Fprintf(cmd.OutOrStdout(), "saved runtime end-user proximity %s\n", proximity) + return nil +} + +func parseRuntimeSetEndUserProximity(value string) (cliruntime.EndUserProximity, error) { + switch strings.TrimSpace(value) { + case string(cliruntime.EndUserProximityLocal): + return cliruntime.EndUserProximityLocal, nil + case string(cliruntime.EndUserProximityRemote): + return cliruntime.EndUserProximityRemote, nil + case "", "auto-detect": + return "", errors.New("runtime set --end-user-proximity must be local or remote") + default: + return "", fmt.Errorf("runtime set --end-user-proximity must be %q or %q", cliruntime.EndUserProximityLocal, cliruntime.EndUserProximityRemote) + } +} + +const runtimeHelpText = `Runtime end-user proximity describes where the agent environment running the CLI +is located relative to the end user's browser. + +Terms: + end user: + The human who asked the agent to do work and can complete browser consent. + + agent: + The AI system acting for the end user, including the model, harness, tool + runner, and environment invoking the tollbit CLI. + + end-user proximity: + Whether the agent environment running the tollbit CLI is local or remote + relative to the end user's browser. + +Values: + local: + The CLI and the end user's browser are available in the same environment. + + remote: + The CLI is running in an agent environment separate from the end user's + browser. The agent may need to relay printed browser instructions to the end + user, then run auth complete after browser approval. + +If end-user proximity is not configured, the CLI attempts to determine it +automatically. + +Guidance: + Prefer runtime set when the agent environment is known. Persisting end-user + proximity lets future commands choose the right authorization flow without + repeating --end-user-proximity on each command. Use the flag only for a + one-off override. + +Commands: + tollbit runtime status + tollbit runtime set --end-user-proximity local + tollbit runtime set --end-user-proximity remote + +Overrides: + --end-user-proximity local|remote overrides end-user proximity for one invocation. + TOLLBIT_RUNTIME_END_USER_PROXIMITY=local|remote sets it from the environment. + TOLLBIT_RUNTIME_STATE_DIR selects where runtime state is read or written. +` diff --git a/internal/client/auth/client.go b/internal/client/auth/client.go index 55dc46b..575ce6a 100644 --- a/internal/client/auth/client.go +++ b/internal/client/auth/client.go @@ -13,6 +13,7 @@ import ( "time" "github.com/rs/zerolog" + "github.com/tollbit/cli/internal/errorsx/problemjson" "github.com/tollbit/cli/internal/tokens/agent" "github.com/tollbit/cli/internal/version" ) @@ -55,6 +56,55 @@ type ( WBA *WebBotAuth `json:"wba,omitempty"` } + ConsentBrowserSelectIconStartRequest struct { + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` + Scope string `json:"scope,omitempty"` + } + + AgentConsentIcon struct { + ID int32 `json:"id"` + Name string `json:"name"` + Art string `json:"art"` + } + + ConsentBrowserSelectIconStartResponse struct { + ChallengeID string `json:"challenge_id"` + ConsentURL string `json:"consent_url"` + ExpiresAt string `json:"expires_at"` + CorrectIcon AgentConsentIcon `json:"correct_icon"` + } + + ConsentBrowserSelectIconTokenRequest struct { + AgentIdentifier string `json:"agent_identifier"` + ChallengeID string `json:"challenge_id"` + CodeVerifier string `json:"code_verifier"` + UA *string `json:"ua,omitempty"` + WBA *WebBotAuth `json:"wba,omitempty"` + } + + ConsentAgentConfirmsIconsStartRequest struct { + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` + Scope string `json:"scope,omitempty"` + } + + ConsentAgentConfirmsIconsStartResponse struct { + ChallengeID string `json:"challenge_id"` + ConsentURL string `json:"consent_url"` + ExpiresAt string `json:"expires_at"` + IconNames []string `json:"icon_names"` + } + + ConsentAgentConfirmsIconsTokenRequest struct { + AgentIdentifier string `json:"agent_identifier"` + ChallengeID string `json:"challenge_id"` + IconNames []string `json:"icon_names"` + CodeVerifier string `json:"code_verifier"` + UA *string `json:"ua,omitempty"` + WBA *WebBotAuth `json:"wba,omitempty"` + } + RefreshTokenGrantRequest struct { AgentIdentifier string RefreshToken string @@ -96,6 +146,8 @@ type ( Code string `json:"code,omitempty"` CodeVerifier string `json:"code_verifier,omitempty"` RedirectURI string `json:"redirect_uri,omitempty"` + ChallengeID string `json:"challenge_id,omitempty"` + IconNames []string `json:"icon_names,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` UA *string `json:"ua,omitempty"` WBA *WebBotAuth `json:"wba,omitempty"` @@ -109,6 +161,14 @@ type ( requestOption func(*http.Request) ) +const ( + grantTypeSelfAttested = "self_attested" + grantTypeRefreshToken = "refresh_token" + grantTypeConsentRedirect = "consent:redirect" + grantTypeConsentBrowserSelectIcon = "consent:browser_select_icon" + grantTypeConsentAgentConfirmsIcons = "consent:agent_confirms_icons" +) + func New(cfg ClientConfig) (*Client, error) { baseURL := strings.TrimSpace(cfg.BaseURL) if baseURL == "" { @@ -135,7 +195,7 @@ func (c *Client) CreateAgentToken(ctx context.Context, identity AgentIdentity, o } req := AgentTokenRequest{ - GrantType: "self_attested", + GrantType: grantTypeSelfAttested, AgentIdentifier: identity.Name, TTLSeconds: opts.TTLSeconds, UA: ua, @@ -175,6 +235,116 @@ func (c *Client) StartAgentConsentRedirect(ctx context.Context, token agent.Toke return out, nil } +func (c *Client) StartAgentConsentBrowserSelectIcon(ctx context.Context, token agent.Token, req ConsentBrowserSelectIconStartRequest) (ConsentBrowserSelectIconStartResponse, error) { + if strings.TrimSpace(token.RawToken) == "" { + return ConsentBrowserSelectIconStartResponse{}, errors.New("agent token is required") + } + if strings.TrimSpace(req.CodeChallenge) == "" { + return ConsentBrowserSelectIconStartResponse{}, errors.New("code challenge is required") + } + if strings.TrimSpace(req.CodeChallengeMethod) == "" { + req.CodeChallengeMethod = "S256" + } + + u := c.resolve("/agent/v1/consent/browser-select-icon/start") + var out ConsentBrowserSelectIconStartResponse + if err := c.doJSON(ctx, http.MethodPost, u.String(), req, &out, withBearerToken(token)); err != nil { + return ConsentBrowserSelectIconStartResponse{}, err + } + return out, nil +} + +func (c *Client) RedeemAgentConsentBrowserSelectIcon(ctx context.Context, token agent.Token, req ConsentBrowserSelectIconTokenRequest) (AgentTokenResponse, error) { + if strings.TrimSpace(token.RawToken) == "" { + return AgentTokenResponse{}, errors.New("agent token is required") + } + if strings.TrimSpace(req.AgentIdentifier) == "" { + return AgentTokenResponse{}, errors.New("agent identifier is required") + } + if strings.TrimSpace(req.ChallengeID) == "" { + return AgentTokenResponse{}, errors.New("challenge id is required") + } + if strings.TrimSpace(req.CodeVerifier) == "" { + return AgentTokenResponse{}, errors.New("code verifier is required") + } + + body := identityTokenRequest{ + GrantType: grantTypeConsentBrowserSelectIcon, + AgentIdentifier: strings.TrimSpace(req.AgentIdentifier), + ChallengeID: strings.TrimSpace(req.ChallengeID), + CodeVerifier: req.CodeVerifier, + UA: req.UA, + WBA: req.WBA, + } + u := c.resolve("/agent/v1/tokens/identity") + var out AgentTokenResponse + if err := c.doJSON(ctx, http.MethodPost, u.String(), body, &out, withBearerToken(token)); err != nil { + return AgentTokenResponse{}, err + } + return out, nil +} + +func (c *Client) StartAgentConsentAgentConfirmsIcons(ctx context.Context, token agent.Token, req ConsentAgentConfirmsIconsStartRequest) (ConsentAgentConfirmsIconsStartResponse, error) { + if strings.TrimSpace(token.RawToken) == "" { + return ConsentAgentConfirmsIconsStartResponse{}, errors.New("agent token is required") + } + if strings.TrimSpace(req.CodeChallenge) == "" { + return ConsentAgentConfirmsIconsStartResponse{}, errors.New("code challenge is required") + } + if strings.TrimSpace(req.CodeChallengeMethod) == "" { + req.CodeChallengeMethod = "S256" + } + + u := c.resolve("/agent/v1/consent/agent-confirms-icons/start") + var out ConsentAgentConfirmsIconsStartResponse + if err := c.doJSON(ctx, http.MethodPost, u.String(), req, &out, withBearerToken(token)); err != nil { + return ConsentAgentConfirmsIconsStartResponse{}, err + } + return out, nil +} + +func (c *Client) RedeemAgentConsentAgentConfirmsIcons(ctx context.Context, token agent.Token, req ConsentAgentConfirmsIconsTokenRequest) (AgentTokenResponse, error) { + if strings.TrimSpace(token.RawToken) == "" { + return AgentTokenResponse{}, errors.New("agent token is required") + } + if strings.TrimSpace(req.AgentIdentifier) == "" { + return AgentTokenResponse{}, errors.New("agent identifier is required") + } + if strings.TrimSpace(req.ChallengeID) == "" { + return AgentTokenResponse{}, errors.New("challenge id is required") + } + if strings.TrimSpace(req.CodeVerifier) == "" { + return AgentTokenResponse{}, errors.New("code verifier is required") + } + if len(req.IconNames) != 3 { + return AgentTokenResponse{}, errors.New("exactly 3 icon names are required") + } + iconNames := make([]string, 0, len(req.IconNames)) + for _, name := range req.IconNames { + name = strings.TrimSpace(name) + if name == "" { + return AgentTokenResponse{}, errors.New("icon names must be non-empty") + } + iconNames = append(iconNames, name) + } + + body := identityTokenRequest{ + GrantType: grantTypeConsentAgentConfirmsIcons, + AgentIdentifier: strings.TrimSpace(req.AgentIdentifier), + ChallengeID: strings.TrimSpace(req.ChallengeID), + IconNames: iconNames, + CodeVerifier: req.CodeVerifier, + UA: req.UA, + WBA: req.WBA, + } + u := c.resolve("/agent/v1/tokens/identity") + var out AgentTokenResponse + if err := c.doJSON(ctx, http.MethodPost, u.String(), body, &out, withBearerToken(token)); err != nil { + return AgentTokenResponse{}, err + } + return out, nil +} + func (c *Client) RedeemAgentConsentRedirect(ctx context.Context, token agent.Token, req ConsentRedirectTokenRequest) (AgentTokenResponse, error) { if strings.TrimSpace(token.RawToken) == "" { return AgentTokenResponse{}, errors.New("agent token is required") @@ -193,7 +363,7 @@ func (c *Client) RedeemAgentConsentRedirect(ctx context.Context, token agent.Tok } body := identityTokenRequest{ - GrantType: "consent", + GrantType: grantTypeConsentRedirect, AgentIdentifier: strings.TrimSpace(req.AgentIdentifier), Code: req.Code, CodeVerifier: req.CodeVerifier, @@ -221,7 +391,7 @@ func (c *Client) RefreshAgentToken(ctx context.Context, baseToken agent.Token, r } body := identityTokenRequest{ - GrantType: "refresh_token", + GrantType: grantTypeRefreshToken, AgentIdentifier: strings.TrimSpace(req.AgentIdentifier), RefreshToken: strings.TrimSpace(req.RefreshToken), UA: req.UA, @@ -319,15 +489,9 @@ func apiErrorFromBody(status string, body []byte) error { if len(body) == 0 { return fmt.Errorf("request failed: %s", status) } - // TODO: Use internal/errorsx.ParseResponseError once client error handling is consolidated. - var problem struct { - Detail *string `json:"detail"` - Title string `json:"title"` - Status int `json:"status"` - Type string `json:"type"` - } - if err := json.Unmarshal(body, &problem); err == nil && problem.Detail != nil { - return fmt.Errorf("%s: %s", status, *problem.Detail) + problem, err := problemjson.Parse(body) + if err == nil { + return problem } return fmt.Errorf("request failed: %s: %s", status, strings.TrimSpace(string(body))) } diff --git a/internal/client/auth/client_test.go b/internal/client/auth/client_test.go index d9b7ac2..46e56b4 100644 --- a/internal/client/auth/client_test.go +++ b/internal/client/auth/client_test.go @@ -168,11 +168,14 @@ func TestClientRedeemAgentConsentRedirect(t *testing.T) { if r.Header.Get("Authorization") != "Bearer unlinked-token" { t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization")) } - var body ConsentRedirectTokenRequest + var body identityTokenRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatal(err) } - if body.AgentIdentifier != "agent-test" || body.Code != "code" || body.CodeVerifier != "verifier" || body.RedirectURI != "http://127.0.0.1:1234/callback" { + if body.GrantType == "consent" { + t.Fatal("bare consent grant_type is not accepted") + } + if body.GrantType != grantTypeConsentRedirect || body.AgentIdentifier != "agent-test" || body.Code != "code" || body.CodeVerifier != "verifier" || body.RedirectURI != "http://127.0.0.1:1234/callback" { t.Fatalf("unexpected body: %#v", body) } sawRequest = true @@ -303,3 +306,191 @@ func TestClientSurfacesProblemJSON(t *testing.T) { t.Fatalf("expected error detail %q, got %v", detail, err) } } + +func TestClientStartAgentConsentBrowserSelectIcon(t *testing.T) { + var sawRequest bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.RequestURI() != "/agent/v1/consent/browser-select-icon/start" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.RequestURI()) + } + if r.Header.Get("Authorization") != "Bearer unlinked-token" { + t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization")) + } + var body ConsentBrowserSelectIconStartRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.CodeChallenge != "challenge" || body.CodeChallengeMethod != "S256" || body.Scope != "offline_access" { + t.Fatalf("unexpected body: %#v", body) + } + sawRequest = true + _ = json.NewEncoder(w).Encode(ConsentBrowserSelectIconStartResponse{ + ChallengeID: "ach_123", + ConsentURL: "https://auth.example.test/oauth/consent/browser-select-icon?consent_challenge=ach_123", + ExpiresAt: "2026-06-02T12:00:00Z", + CorrectIcon: AgentConsentIcon{ID: 17, Name: "ACORN", Art: "ACORN\n art"}, + }) + })) + defer srv.Close() + + c, err := New(ClientConfig{BaseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + resp, err := c.StartAgentConsentBrowserSelectIcon(context.Background(), agent.Token{RawToken: "unlinked-token"}, ConsentBrowserSelectIconStartRequest{ + CodeChallenge: "challenge", + Scope: "offline_access", + }) + if err != nil { + t.Fatal(err) + } + if !sawRequest || resp.ChallengeID != "ach_123" || resp.CorrectIcon.Name != "ACORN" { + t.Fatalf("unexpected response: %#v", resp) + } +} + +func TestClientRedeemAgentConsentBrowserSelectIcon(t *testing.T) { + var sawRequest bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.RequestURI() != "/agent/v1/tokens/identity" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.RequestURI()) + } + if r.Header.Get("Authorization") != "Bearer unlinked-token" { + t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization")) + } + var body identityTokenRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.GrantType == "consent" { + t.Fatal("bare consent grant_type is not accepted") + } + if body.GrantType != grantTypeConsentBrowserSelectIcon || body.AgentIdentifier != "agent-test" || body.ChallengeID != "ach_123" || body.CodeVerifier != "verifier" { + t.Fatalf("unexpected body: %#v", body) + } + if body.UA == nil || *body.UA != "agent-test/0.1" { + t.Fatalf("expected ua, got %#v", body.UA) + } + sawRequest = true + _ = json.NewEncoder(w).Encode(AgentTokenResponse{Token: "linked-token"}) + })) + defer srv.Close() + + c, err := New(ClientConfig{BaseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + ua := "agent-test/0.1" + resp, err := c.RedeemAgentConsentBrowserSelectIcon(context.Background(), agent.Token{RawToken: "unlinked-token"}, ConsentBrowserSelectIconTokenRequest{ + AgentIdentifier: "agent-test", + ChallengeID: "ach_123", + CodeVerifier: "verifier", + UA: &ua, + }) + if err != nil { + t.Fatal(err) + } + if !sawRequest || resp.Token != "linked-token" { + t.Fatalf("unexpected response: %#v", resp) + } +} + +func TestClientStartAgentConsentAgentConfirmsIcons(t *testing.T) { + var sawRequest bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.RequestURI() != "/agent/v1/consent/agent-confirms-icons/start" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.RequestURI()) + } + if r.Header.Get("Authorization") != "Bearer unlinked-token" { + t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization")) + } + var body ConsentAgentConfirmsIconsStartRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.CodeChallenge != "challenge" || body.CodeChallengeMethod != "S256" || body.Scope != "offline_access" { + t.Fatalf("unexpected body: %#v", body) + } + sawRequest = true + _ = json.NewEncoder(w).Encode(ConsentAgentConfirmsIconsStartResponse{ + ChallengeID: "ach_123", + ConsentURL: "https://auth.example.test/oauth/consent/agent-confirms-icons?consent_challenge=ach_123", + ExpiresAt: "2026-06-02T12:00:00Z", + IconNames: []string{"ANCHOR", "FOX", "STAR"}, + }) + })) + defer srv.Close() + + c, err := New(ClientConfig{BaseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + resp, err := c.StartAgentConsentAgentConfirmsIcons(context.Background(), agent.Token{RawToken: "unlinked-token"}, ConsentAgentConfirmsIconsStartRequest{ + CodeChallenge: "challenge", + Scope: "offline_access", + }) + if err != nil { + t.Fatal(err) + } + if !sawRequest || resp.ChallengeID != "ach_123" || len(resp.IconNames) != 3 || resp.IconNames[1] != "FOX" { + t.Fatalf("unexpected response: %#v", resp) + } +} + +func TestClientRedeemAgentConsentAgentConfirmsIcons(t *testing.T) { + var sawRequest bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.RequestURI() != "/agent/v1/tokens/identity" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.RequestURI()) + } + if r.Header.Get("Authorization") != "Bearer unlinked-token" { + t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization")) + } + var body identityTokenRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.GrantType != grantTypeConsentAgentConfirmsIcons || body.AgentIdentifier != "agent-test" || body.ChallengeID != "ach_123" || body.CodeVerifier != "verifier" { + t.Fatalf("unexpected body: %#v", body) + } + if len(body.IconNames) != 3 || body.IconNames[0] != "FOX" || body.IconNames[1] != "ANCHOR" || body.IconNames[2] != "STAR" { + t.Fatalf("unexpected icon names: %#v", body.IconNames) + } + sawRequest = true + _ = json.NewEncoder(w).Encode(AgentTokenResponse{Token: "linked-token"}) + })) + defer srv.Close() + + c, err := New(ClientConfig{BaseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + resp, err := c.RedeemAgentConsentAgentConfirmsIcons(context.Background(), agent.Token{RawToken: "unlinked-token"}, ConsentAgentConfirmsIconsTokenRequest{ + AgentIdentifier: "agent-test", + ChallengeID: "ach_123", + IconNames: []string{"FOX", "ANCHOR", "STAR"}, + CodeVerifier: "verifier", + }) + if err != nil { + t.Fatal(err) + } + if !sawRequest || resp.Token != "linked-token" { + t.Fatalf("unexpected response: %#v", resp) + } +} + +func TestClientRedeemAgentConsentAgentConfirmsIconsRequiresThreeIcons(t *testing.T) { + c, err := New(ClientConfig{BaseURL: "https://auth.example"}) + if err != nil { + t.Fatal(err) + } + _, err = c.RedeemAgentConsentAgentConfirmsIcons(context.Background(), agent.Token{RawToken: "unlinked-token"}, ConsentAgentConfirmsIconsTokenRequest{ + AgentIdentifier: "agent-test", + ChallengeID: "ach_123", + IconNames: []string{"FOX", "ANCHOR"}, + CodeVerifier: "verifier", + }) + if err == nil || !strings.Contains(err.Error(), "exactly 3 icon names are required") { + t.Fatalf("expected exactly-3 error, got %v", err) + } +} diff --git a/internal/cliruntime/browser.go b/internal/cliruntime/browser.go new file mode 100644 index 0000000..d66bd2d --- /dev/null +++ b/internal/cliruntime/browser.go @@ -0,0 +1,33 @@ +package cliruntime + +import ( + "fmt" + "net/url" + "os/exec" + "runtime" +) + +func (r *Runtime) OpenBrowser(rawURL string) error { + if err := validateBrowserURL(rawURL); err != nil { + return err + } + switch runtime.GOOS { + case "darwin": + return exec.Command("open", rawURL).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start() + default: + return exec.Command("xdg-open", rawURL).Start() + } +} + +func validateBrowserURL(rawURL string) error { + u, err := url.ParseRequestURI(rawURL) + if err != nil { + return err + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("refusing to open URL with scheme %q; only http and https are allowed", u.Scheme) + } + return nil +} diff --git a/internal/cliruntime/runtime.go b/internal/cliruntime/runtime.go new file mode 100644 index 0000000..5d4a8e8 --- /dev/null +++ b/internal/cliruntime/runtime.go @@ -0,0 +1,220 @@ +package cliruntime + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tollbit/cli/internal/configuration" +) + +const ( + EndUserProximityLocal EndUserProximity = "local" + EndUserProximityRemote EndUserProximity = "remote" + + EndUserProximitySourceConfigured EndUserProximitySource = "configured" + EndUserProximitySourceSavedRuntimeState EndUserProximitySource = "saved_runtime_state" + EndUserProximitySourceAutoDetect EndUserProximitySource = "auto_detect" + + stateFilename = "runtime.json" +) + +type ( + EndUserProximity string + EndUserProximitySource string + + Config struct { + ConfiguredEndUserProximity string + StateDir string + } + + Runtime struct { + configuredEndUserProximity string + stateDir string + statePath string + } + + Status struct { + ConfiguredEndUserProximity string `json:"configured_end_user_proximity"` + SavedEndUserProximity EndUserProximity `json:"saved_end_user_proximity,omitempty"` + EndUserProximity EndUserProximity `json:"end_user_proximity"` + EndUserProximitySource EndUserProximitySource `json:"end_user_proximity_source"` + StateDir string `json:"state_dir"` + StatePath string `json:"state_path"` + StateExists bool `json:"state_exists"` + } + + state struct { + EndUserProximity EndUserProximity `json:"end_user_proximity"` + UpdatedAt string `json:"updated_at"` + } +) + +func New(cfg Config) (*Runtime, error) { + configuredEndUserProximity := strings.TrimSpace(cfg.ConfiguredEndUserProximity) + if configuredEndUserProximity == "" { + configuredEndUserProximity = configuration.RuntimeEndUserProximityAutoDetect + } + stateDir := strings.TrimSpace(cfg.StateDir) + if stateDir == "" { + return nil, errors.New("runtime state dir is required") + } + return &Runtime{ + configuredEndUserProximity: configuredEndUserProximity, + stateDir: stateDir, + statePath: filepath.Join(stateDir, stateFilename), + }, nil +} + +func (r *Runtime) EndUserProximity(ctx context.Context) (EndUserProximity, error) { + switch r.configuredEndUserProximity { + case configuration.RuntimeEndUserProximityLocal: + return EndUserProximityLocal, nil + case configuration.RuntimeEndUserProximityRemote: + return EndUserProximityRemote, nil + case configuration.RuntimeEndUserProximityAutoDetect: + st, exists, err := r.readState(ctx) + if err != nil { + return "", err + } + if exists { + return st.EndUserProximity, nil + } + return EndUserProximityRemote, nil + default: + return "", fmt.Errorf("runtime.end_user_proximity must be %q, %q, or %q", configuration.RuntimeEndUserProximityAutoDetect, configuration.RuntimeEndUserProximityLocal, configuration.RuntimeEndUserProximityRemote) + } +} + +func (r *Runtime) SetEndUserProximity(ctx context.Context, proximity EndUserProximity) error { + if err := validateEndUserProximity(proximity); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if err := os.MkdirAll(r.stateDir, 0o700); err != nil { + return fmt.Errorf("create runtime state directory: %w", err) + } + b, err := json.MarshalIndent(state{ + EndUserProximity: proximity, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + }, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + return writeFile(ctx, r.statePath, b) +} + +func (r *Runtime) Status(ctx context.Context) (Status, error) { + st, exists, err := r.readState(ctx) + if err != nil { + return Status{}, err + } + var proximity EndUserProximity + var source EndUserProximitySource + switch r.configuredEndUserProximity { + case configuration.RuntimeEndUserProximityLocal: + proximity = EndUserProximityLocal + source = EndUserProximitySourceConfigured + case configuration.RuntimeEndUserProximityRemote: + proximity = EndUserProximityRemote + source = EndUserProximitySourceConfigured + case configuration.RuntimeEndUserProximityAutoDetect: + if exists { + proximity = st.EndUserProximity + source = EndUserProximitySourceSavedRuntimeState + } else { + proximity = EndUserProximityRemote + source = EndUserProximitySourceAutoDetect + } + default: + return Status{}, fmt.Errorf("runtime.end_user_proximity must be %q, %q, or %q", configuration.RuntimeEndUserProximityAutoDetect, configuration.RuntimeEndUserProximityLocal, configuration.RuntimeEndUserProximityRemote) + } + savedProximity := EndUserProximity("") + if exists { + savedProximity = st.EndUserProximity + } + return Status{ + ConfiguredEndUserProximity: r.configuredEndUserProximity, + SavedEndUserProximity: savedProximity, + EndUserProximity: proximity, + EndUserProximitySource: source, + StateDir: r.stateDir, + StatePath: r.statePath, + StateExists: exists, + }, nil +} + +func (r *Runtime) StatePath() string { + return r.statePath +} + +func (r *Runtime) readState(ctx context.Context) (state, bool, error) { + if err := ctx.Err(); err != nil { + return state{}, false, err + } + raw, err := os.ReadFile(r.statePath) + if os.IsNotExist(err) { + return state{}, false, nil + } + if err != nil { + return state{}, false, fmt.Errorf("read runtime state: %w", err) + } + var st state + if err := json.Unmarshal(raw, &st); err != nil { + return state{}, false, fmt.Errorf("parse runtime state: %w", err) + } + if err := validateEndUserProximity(st.EndUserProximity); err != nil { + return state{}, false, fmt.Errorf("runtime state is invalid: %w", err) + } + return st, true, nil +} + +func validateEndUserProximity(proximity EndUserProximity) error { + switch proximity { + case EndUserProximityLocal, EndUserProximityRemote: + return nil + default: + return fmt.Errorf("runtime end-user proximity must be %q or %q", EndUserProximityLocal, EndUserProximityRemote) + } +} + +func writeFile(ctx context.Context, path string, content []byte) error { + if err := ctx.Err(); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create runtime state directory: %w", err) + } + f, err := os.CreateTemp(filepath.Dir(path), ".runtime-*.tmp") + if err != nil { + return fmt.Errorf("create runtime state temp file: %w", err) + } + tmp := f.Name() + defer func() { _ = os.Remove(tmp) }() + if err := f.Chmod(0o600); err != nil { + _ = f.Close() + return fmt.Errorf("set runtime state temp file permissions: %w", err) + } + if _, err := f.Write(content); err != nil { + _ = f.Close() + return fmt.Errorf("write runtime state: %w", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("close runtime state temp file: %w", err) + } + if err := ctx.Err(); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("save runtime state: %w", err) + } + return nil +} diff --git a/internal/cliruntime/runtime_test.go b/internal/cliruntime/runtime_test.go new file mode 100644 index 0000000..432706c --- /dev/null +++ b/internal/cliruntime/runtime_test.go @@ -0,0 +1,192 @@ +package cliruntime + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/tollbit/cli/internal/configuration" +) + +func TestEndUserProximityUsesConfiguredValueBeforeState(t *testing.T) { + dir := t.TempDir() + rt, err := New(Config{ConfiguredEndUserProximity: configuration.RuntimeEndUserProximityLocal, StateDir: dir}) + if err != nil { + t.Fatal(err) + } + if err := rt.SetEndUserProximity(context.Background(), EndUserProximityRemote); err != nil { + t.Fatal(err) + } + + got, err := rt.EndUserProximity(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != EndUserProximityLocal { + t.Fatalf("expected configured end-user proximity to override state, got %q", got) + } +} + +func TestAutoDetectReadsRuntimeState(t *testing.T) { + rt, err := New(Config{ConfiguredEndUserProximity: configuration.RuntimeEndUserProximityAutoDetect, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if err := rt.SetEndUserProximity(context.Background(), EndUserProximityLocal); err != nil { + t.Fatal(err) + } + + got, err := rt.EndUserProximity(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != EndUserProximityLocal { + t.Fatalf("expected end-user proximity from runtime state, got %q", got) + } +} + +func TestAutoDetectDefaultsRemoteWithoutRuntimeState(t *testing.T) { + rt, err := New(Config{ConfiguredEndUserProximity: configuration.RuntimeEndUserProximityAutoDetect, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + got, err := rt.EndUserProximity(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != EndUserProximityRemote { + t.Fatalf("expected remote fallback, got %q", got) + } +} + +func TestStatusReportsEndUserProximitySource(t *testing.T) { + tests := []struct { + name string + configured string + saved EndUserProximity + wantProximity EndUserProximity + wantSaved EndUserProximity + wantSource EndUserProximitySource + wantStateExists bool + }{ + { + name: "configured local overrides saved state", + configured: configuration.RuntimeEndUserProximityLocal, + saved: EndUserProximityRemote, + wantProximity: EndUserProximityLocal, + wantSaved: EndUserProximityRemote, + wantSource: EndUserProximitySourceConfigured, + wantStateExists: true, + }, + { + name: "configured remote", + configured: configuration.RuntimeEndUserProximityRemote, + wantProximity: EndUserProximityRemote, + wantSource: EndUserProximitySourceConfigured, + wantStateExists: false, + }, + { + name: "auto detect reads saved runtime state", + configured: configuration.RuntimeEndUserProximityAutoDetect, + saved: EndUserProximityLocal, + wantProximity: EndUserProximityLocal, + wantSaved: EndUserProximityLocal, + wantSource: EndUserProximitySourceSavedRuntimeState, + wantStateExists: true, + }, + { + name: "auto detect defaults remote without saved state", + configured: configuration.RuntimeEndUserProximityAutoDetect, + wantProximity: EndUserProximityRemote, + wantSource: EndUserProximitySourceAutoDetect, + wantStateExists: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt, err := New(Config{ConfiguredEndUserProximity: tt.configured, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if tt.saved != "" { + if err := rt.SetEndUserProximity(context.Background(), tt.saved); err != nil { + t.Fatal(err) + } + } + status, err := rt.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if status.EndUserProximity != tt.wantProximity || status.EndUserProximitySource != tt.wantSource || status.StateExists != tt.wantStateExists || status.SavedEndUserProximity != tt.wantSaved { + t.Fatalf("unexpected status: %#v", status) + } + }) + } +} + +func TestSetEndUserProximityWritesRuntimeStateSecurely(t *testing.T) { + dir := t.TempDir() + rt, err := New(Config{ConfiguredEndUserProximity: configuration.RuntimeEndUserProximityAutoDetect, StateDir: dir}) + if err != nil { + t.Fatal(err) + } + + if err := rt.SetEndUserProximity(context.Background(), EndUserProximityRemote); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, stateFilename)) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("expected runtime state mode 0600, got %#o", got) + } + raw, err := os.ReadFile(filepath.Join(dir, stateFilename)) + if err != nil { + t.Fatal(err) + } + var st state + if err := json.Unmarshal(raw, &st); err != nil { + t.Fatal(err) + } + if st.EndUserProximity != EndUserProximityRemote || st.UpdatedAt == "" { + t.Fatalf("unexpected runtime state: %#v", st) + } +} + +func TestSetEndUserProximityRejectsAutoDetect(t *testing.T) { + rt, err := New(Config{ConfiguredEndUserProximity: configuration.RuntimeEndUserProximityAutoDetect, StateDir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + + if err := rt.SetEndUserProximity(context.Background(), EndUserProximity("auto-detect")); err == nil { + t.Fatal("expected error") + } +} + +func TestValidateBrowserURL(t *testing.T) { + t.Parallel() + + for _, rawURL := range []string{"http://x", "https://x"} { + if err := validateBrowserURL(rawURL); err != nil { + t.Fatalf("expected %q to be allowed, got %v", rawURL, err) + } + } + + for _, rawURL := range []string{ + "file:///etc/passwd", + "javascript:alert(1)", + "custom://x", + "", + "example.com/path", + } { + if err := validateBrowserURL(rawURL); err == nil { + t.Fatalf("expected %q to be rejected", rawURL) + } + } +} diff --git a/internal/configuration/config.go b/internal/configuration/config.go index e8d66cc..f85f3ef 100644 --- a/internal/configuration/config.go +++ b/internal/configuration/config.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" mapstructure "github.com/go-viper/mapstructure/v2" @@ -12,7 +13,18 @@ import ( "github.com/tollbit/cli/internal/configuration/platformdefaults" ) -const envPrefix = "TOLLBIT" +const ( + developmentConfigFile = "tb-cli.config.development.yaml" + envPrefix = "TOLLBIT" + + RuntimeEndUserProximityAutoDetect = "auto-detect" + RuntimeEndUserProximityLocal = "local" + RuntimeEndUserProximityRemote = "remote" + + ConsentStrategyRedirect = "redirect" + ConsentStrategyBrowserSelectIcon = "browser_select_icon" + ConsentStrategyAgentConfirmsIcons = "agent_confirms_icons" +) func AssembleConfiguration(defaultConfig []byte) (Config, error) { return assembleConfiguration(defaultConfig, os.Getwd) @@ -25,9 +37,6 @@ func assembleConfiguration(defaultConfig []byte, getwd func() (string, error)) ( v := viper.New() v.SetConfigType("yaml") - v.SetEnvPrefix(envPrefix) - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - v.AutomaticEnv() if err := v.ReadConfig(bytes.NewReader(defaultConfig)); err != nil { return Config{}, fmt.Errorf("read embedded config: %w", err) @@ -37,14 +46,19 @@ func assembleConfiguration(defaultConfig []byte, getwd func() (string, error)) ( if err != nil { return Config{}, fmt.Errorf("get working directory: %w", err) } - if err := mergeDevConfig(v, wd); err != nil { - return Config{}, err + if IsDev { + if err := mergeConfigIfExists(v, filepath.Join(wd, developmentConfigFile)); err != nil { + return Config{}, err + } } var config Config if err := v.Unmarshal(&config, viper.DecodeHook(mapstructure.StringToTimeDurationHookFunc())); err != nil { return Config{}, fmt.Errorf("unmarshal config: %w", err) } + if err := applyEnv(&config); err != nil { + return Config{}, err + } config, err = resolveDefaults(config) if err != nil { return Config{}, err @@ -56,26 +70,41 @@ func assembleConfiguration(defaultConfig []byte, getwd func() (string, error)) ( return config, nil } -// parseEmbeddedYAML unmarshals YAML defaults with no AutomaticEnv and no -// development-config merge. Used by release PinEndpoints so pins match -// tb-cli.config.yaml exactly. -func parseEmbeddedYAML(data []byte) (Config, error) { - if len(data) == 0 { - return Config{}, errors.New("default config is required") - } - v := viper.New() - v.SetConfigType("yaml") - if err := v.ReadConfig(bytes.NewReader(data)); err != nil { - return Config{}, fmt.Errorf("read embedded config: %w", err) +func mergeConfigIfExists(v *viper.Viper, path string) error { + if _, err := os.Stat(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("stat development config: %w", err) } - var config Config - if err := v.Unmarshal(&config, viper.DecodeHook(mapstructure.StringToTimeDurationHookFunc())); err != nil { - return Config{}, fmt.Errorf("unmarshal config: %w", err) + v.SetConfigFile(path) + if err := v.MergeInConfig(); err != nil { + return fmt.Errorf("merge development config: %w", err) } - return config, nil + return nil } func resolveDefaults(config Config) (Config, error) { + config.Runtime.EndUserProximity = strings.TrimSpace(config.Runtime.EndUserProximity) + if config.Runtime.EndUserProximity == "" { + config.Runtime.EndUserProximity = RuntimeEndUserProximityAutoDetect + } + runtimeStateDir, err := platformdefaults.StateDir(config.Runtime.StateDir) + if err != nil { + return Config{}, fmt.Errorf("resolve runtime.state_dir: %w", err) + } + config.Runtime.StateDir = runtimeStateDir + // Blank strategy defaults mirror tollbit-cli's resolveDefaults; the shipped + // remote strategy comes from tb-cli.config.yaml, which may differ. + config.Auth.Consent.Strategy.Local = strings.TrimSpace(config.Auth.Consent.Strategy.Local) + if config.Auth.Consent.Strategy.Local == "" { + config.Auth.Consent.Strategy.Local = ConsentStrategyRedirect + } + config.Auth.Consent.Strategy.Remote = strings.TrimSpace(config.Auth.Consent.Strategy.Remote) + if config.Auth.Consent.Strategy.Remote == "" { + config.Auth.Consent.Strategy.Remote = ConsentStrategyBrowserSelectIcon + } + storageDir, err := platformdefaults.CredentialsStorageDir(config.Credentials.StorageDir) if err != nil { return Config{}, fmt.Errorf("resolve credentials.storage_dir: %w", err) @@ -91,6 +120,20 @@ func validate(config Config) error { if strings.TrimSpace(config.Auth.BaseURL) == "" { return errors.New("auth.base_url is required") } + switch config.Runtime.EndUserProximity { + case RuntimeEndUserProximityAutoDetect, RuntimeEndUserProximityLocal, RuntimeEndUserProximityRemote: + default: + return fmt.Errorf("runtime.end_user_proximity must be %q, %q, or %q", RuntimeEndUserProximityAutoDetect, RuntimeEndUserProximityLocal, RuntimeEndUserProximityRemote) + } + if strings.TrimSpace(config.Runtime.StateDir) == "" { + return errors.New("runtime.state_dir is required") + } + if err := validateConsentStrategy("auth.consent.strategy.local", config.Auth.Consent.Strategy.Local); err != nil { + return err + } + if err := validateConsentStrategy("auth.consent.strategy.remote", config.Auth.Consent.Strategy.Remote); err != nil { + return err + } if strings.TrimSpace(config.Agent.DefaultName) == "" { return errors.New("agent.default_name is required") } @@ -114,3 +157,23 @@ func validate(config Config) error { } return nil } + +func ResolveConsentStrategy(config Config) string { + switch config.Runtime.EndUserProximity { + case RuntimeEndUserProximityLocal: + return config.Auth.Consent.Strategy.Local + case RuntimeEndUserProximityAutoDetect, RuntimeEndUserProximityRemote: + return config.Auth.Consent.Strategy.Remote + default: + return config.Auth.Consent.Strategy.Remote + } +} + +func validateConsentStrategy(name, strategy string) error { + switch strategy { + case ConsentStrategyRedirect, ConsentStrategyBrowserSelectIcon, ConsentStrategyAgentConfirmsIcons: + return nil + default: + return fmt.Errorf("%s must be %q, %q, or %q", name, ConsentStrategyRedirect, ConsentStrategyBrowserSelectIcon, ConsentStrategyAgentConfirmsIcons) + } +} diff --git a/internal/configuration/config_dev.go b/internal/configuration/config_dev.go deleted file mode 100644 index 1b53aed..0000000 --- a/internal/configuration/config_dev.go +++ /dev/null @@ -1,37 +0,0 @@ -//go:build dev - -package configuration - -import ( - "errors" - "fmt" - "os" - "path/filepath" - - "github.com/spf13/viper" -) - -const developmentConfigFile = "tb-cli.config.development.yaml" - -// PinEndpoints is a no-op in dev builds; overrides are honored. -func PinEndpoints(c Config) (Config, error) { return c, nil } - -// mergeDevConfig merges ./tb-cli.config.development.yaml from the working -// directory when present (developer convenience). Never compiled into release. -func mergeDevConfig(v *viper.Viper, wd string) error { - return mergeConfigIfExists(v, filepath.Join(wd, developmentConfigFile)) -} - -func mergeConfigIfExists(v *viper.Viper, path string) error { - if _, err := os.Stat(path); err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return fmt.Errorf("stat development config: %w", err) - } - v.SetConfigFile(path) - if err := v.MergeInConfig(); err != nil { - return fmt.Errorf("merge development config: %w", err) - } - return nil -} diff --git a/internal/configuration/config_dev_test.go b/internal/configuration/config_dev_test.go index 3d009fe..e941f78 100644 --- a/internal/configuration/config_dev_test.go +++ b/internal/configuration/config_dev_test.go @@ -31,6 +31,52 @@ func TestAssembleConfigurationEnvOverridesFiles(t *testing.T) { } } +func TestAssembleConfigurationAppliesDevEndpointEnv(t *testing.T) { + t.Setenv("TOLLBIT_AUTH_BASE_URL", "https://oauth-development.example") + t.Setenv("TOLLBIT_GATEWAY_BASE_URL", "https://gateway-development.example.com") + t.Setenv("TOLLBIT_AGENT_REGISTER_USER_AGENT_URL", "https://hack-development.example/my-agents") + t.Setenv("TOLLBIT_AUTH_BROWSER_CONSENT_CALLBACK_ADDRESS", "127.0.0.1:65432") + + config := assembleTestConfiguration(t, t.TempDir()) + + if config.Auth.BaseURL != "https://oauth-development.example" { + t.Fatalf("expected auth env override, got %q", config.Auth.BaseURL) + } + if config.Gateway.BaseURL != "https://gateway-development.example.com" { + t.Fatalf("expected gateway env override, got %q", config.Gateway.BaseURL) + } + if config.Agent.RegisterUserAgentURL != "https://hack-development.example/my-agents" { + t.Fatalf("expected register URL env override, got %q", config.Agent.RegisterUserAgentURL) + } + if config.Auth.BrowserConsent.CallbackAddress != "127.0.0.1:65432" { + t.Fatalf("expected callback env override, got %q", config.Auth.BrowserConsent.CallbackAddress) + } +} + +func TestAssembleConfigurationAppliesDevConsentStrategyOverrides(t *testing.T) { + dir := t.TempDir() + writeDevelopmentConfig(t, dir, "auth:\n consent:\n strategy:\n local: browser_select_icon\n") + + config := assembleTestConfiguration(t, dir) + if config.Auth.Consent.Strategy.Local != ConsentStrategyBrowserSelectIcon { + t.Fatalf("expected development strategy override, got %q", config.Auth.Consent.Strategy.Local) + } + + t.Setenv("TOLLBIT_AUTH_CONSENT_STRATEGY_LOCAL", ConsentStrategyRedirect) + config = assembleTestConfiguration(t, dir) + if config.Auth.Consent.Strategy.Local != ConsentStrategyRedirect { + t.Fatalf("expected strategy env override to win, got %q", config.Auth.Consent.Strategy.Local) + } +} + +func TestIsConfigurableReportsDevEndpointFields(t *testing.T) { + for _, path := range []string{"auth.base_url", "gateway.base_url", "agent.register_user_agent_url", "auth.browser_consent.callback_address", "auth.consent.strategy.local", "auth.consent.strategy.remote"} { + if !IsConfigurable(path) { + t.Fatalf("expected %s to be configurable in dev build", path) + } + } +} + func writeDevelopmentConfig(t *testing.T, dir string, content string) { t.Helper() path := filepath.Join(dir, developmentConfigFile) diff --git a/internal/configuration/config_release.go b/internal/configuration/config_release.go deleted file mode 100644 index 929e65d..0000000 --- a/internal/configuration/config_release.go +++ /dev/null @@ -1,45 +0,0 @@ -//go:build !dev - -package configuration - -import ( - "fmt" - "sync" - - "github.com/spf13/viper" - tollbitcli "github.com/tollbit/cli" -) - -var ( - releasePinsOnce sync.Once - releasePins Config - releasePinsErr error -) - -// PinEndpoints forces security-sensitive endpoints to the values from the -// embedded tb-cli.config.yaml, discarding env/flag/dev-file overrides. -func PinEndpoints(c Config) (Config, error) { - pins, err := loadReleasePins() - if err != nil { - return Config{}, err - } - c.Auth.BaseURL = pins.Auth.BaseURL - c.Gateway.BaseURL = pins.Gateway.BaseURL - c.Agent.RegisterUserAgentURL = pins.Agent.RegisterUserAgentURL - c.Auth.BrowserConsent.CallbackAddress = pins.Auth.BrowserConsent.CallbackAddress - return c, nil -} - -// mergeDevConfig is a no-op in release builds — the development config file is -// never read, and the filename/merge code is compiled out entirely. -func mergeDevConfig(_ *viper.Viper, _ string) error { return nil } - -func loadReleasePins() (Config, error) { - releasePinsOnce.Do(func() { - releasePins, releasePinsErr = parseEmbeddedYAML(tollbitcli.DefaultConfig) - if releasePinsErr != nil { - releasePinsErr = fmt.Errorf("parse release endpoint pins: %w", releasePinsErr) - } - }) - return releasePins, releasePinsErr -} diff --git a/internal/configuration/config_release_test.go b/internal/configuration/config_release_test.go index 825db8c..4dd8a40 100644 --- a/internal/configuration/config_release_test.go +++ b/internal/configuration/config_release_test.go @@ -25,80 +25,74 @@ func TestAssembleConfigurationIgnoresDevelopmentConfig(t *testing.T) { } } -func TestPinEndpointsOverridesHostileValues(t *testing.T) { - pins, err := parseEmbeddedYAML(tollbitcli.DefaultConfig) - if err != nil { - t.Fatal(err) - } +func TestAssembleConfigurationIgnoresEndpointEnv(t *testing.T) { + t.Setenv("TOLLBIT_AUTH_BASE_URL", "https://evil.example.com") + t.Setenv("TOLLBIT_GATEWAY_BASE_URL", "https://evil.example.com") + t.Setenv("TOLLBIT_AGENT_REGISTER_USER_AGENT_URL", "https://evil.example.com/register") + t.Setenv("TOLLBIT_AUTH_BROWSER_CONSENT_CALLBACK_ADDRESS", "evil.example.com:9") - hostile := "https://evil.example.com" - config := Config{ - Auth: AuthConfig{ - BaseURL: hostile, - BrowserConsent: BrowserConsentConfig{ - CallbackAddress: "evil.example.com:9", - }, - }, - Agent: AgentConfig{ - RegisterUserAgentURL: hostile + "/register", - }, - Gateway: GatewayConfig{ - BaseURL: hostile, - }, - } + config := assembleTestConfiguration(t, t.TempDir()) - got, err := PinEndpoints(config) - if err != nil { - t.Fatal(err) + if config.Auth.BaseURL != "https://oauth.tollbit.com" { + t.Fatalf("expected embedded auth base URL, got %q", config.Auth.BaseURL) } - if got.Auth.BaseURL != pins.Auth.BaseURL { - t.Fatalf("auth base URL: got %q want %q", got.Auth.BaseURL, pins.Auth.BaseURL) - } - if got.Gateway.BaseURL != pins.Gateway.BaseURL { - t.Fatalf("gateway base URL: got %q want %q", got.Gateway.BaseURL, pins.Gateway.BaseURL) + if config.Gateway.BaseURL != "https://gateway.tollbit.com" { + t.Fatalf("expected embedded gateway base URL, got %q", config.Gateway.BaseURL) } - if got.Agent.RegisterUserAgentURL != pins.Agent.RegisterUserAgentURL { - t.Fatalf("register URL: got %q want %q", got.Agent.RegisterUserAgentURL, pins.Agent.RegisterUserAgentURL) + if config.Agent.RegisterUserAgentURL != "https://hack.tollbit.com/my-agents" { + t.Fatalf("expected embedded register URL, got %q", config.Agent.RegisterUserAgentURL) } - if got.Auth.BrowserConsent.CallbackAddress != pins.Auth.BrowserConsent.CallbackAddress { - t.Fatalf("callback address: got %q want %q", got.Auth.BrowserConsent.CallbackAddress, pins.Auth.BrowserConsent.CallbackAddress) + if config.Auth.BrowserConsent.CallbackAddress != "127.0.0.1:54321" { + t.Fatalf("expected embedded callback address, got %q", config.Auth.BrowserConsent.CallbackAddress) } } -func TestReleasePinsMatchEmbeddedConfig(t *testing.T) { - pins, err := parseEmbeddedYAML(tollbitcli.DefaultConfig) - if err != nil { +func TestAssembleConfigurationIgnoresConsentStrategyOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tb-cli.config.development.yaml") + if err := os.WriteFile(path, []byte("auth:\n consent:\n strategy:\n local: browser_select_icon\n"), 0o600); err != nil { t.Fatal(err) } + t.Setenv("TOLLBIT_AUTH_CONSENT_STRATEGY_LOCAL", ConsentStrategyBrowserSelectIcon) - onDisk, err := os.ReadFile(filepath.Join("..", "..", "tb-cli.config.yaml")) - if err != nil { - t.Fatal(err) + config := assembleTestConfiguration(t, dir) + if config.Auth.Consent.Strategy.Local != ConsentStrategyRedirect { + t.Fatalf("expected embedded local strategy in release, got %q", config.Auth.Consent.Strategy.Local) + } +} + +func TestIsConfigurableRejectsDevEndpointFields(t *testing.T) { + for _, path := range []string{"auth.base_url", "gateway.base_url", "agent.register_user_agent_url", "auth.browser_consent.callback_address", "auth.consent.strategy.local", "auth.consent.strategy.remote"} { + if IsConfigurable(path) { + t.Fatalf("expected %s not to be configurable in release build", path) + } } - fromDisk, err := parseEmbeddedYAML(onDisk) +} + +// TestReleaseEmbeddedEndpointsAreProduction preserves the endpoint-pinning +// invariant: release builds cannot override endpoints, so the embedded config +// they lock to must carry the production https endpoints. +func TestReleaseEmbeddedEndpointsAreProduction(t *testing.T) { + config, err := assembleConfiguration(tollbitcli.DefaultConfig, func() (string, error) { return t.TempDir(), nil }) if err != nil { t.Fatal(err) } - if pins.Auth.BaseURL != fromDisk.Auth.BaseURL { - t.Fatalf("auth.base_url embed %q != disk %q", pins.Auth.BaseURL, fromDisk.Auth.BaseURL) + if config.Auth.BaseURL != "https://oauth.tollbit.com" { + t.Fatalf("auth.base_url: got %q", config.Auth.BaseURL) } - if pins.Gateway.BaseURL != fromDisk.Gateway.BaseURL { - t.Fatalf("gateway.base_url embed %q != disk %q", pins.Gateway.BaseURL, fromDisk.Gateway.BaseURL) + if config.Gateway.BaseURL != "https://gateway.tollbit.com" { + t.Fatalf("gateway.base_url: got %q", config.Gateway.BaseURL) } - if pins.Agent.RegisterUserAgentURL != fromDisk.Agent.RegisterUserAgentURL { - t.Fatalf("register_user_agent_url embed %q != disk %q", pins.Agent.RegisterUserAgentURL, fromDisk.Agent.RegisterUserAgentURL) + if config.Agent.RegisterUserAgentURL != "https://hack.tollbit.com/my-agents" { + t.Fatalf("agent.register_user_agent_url: got %q", config.Agent.RegisterUserAgentURL) } - if pins.Auth.BrowserConsent.CallbackAddress != fromDisk.Auth.BrowserConsent.CallbackAddress { - t.Fatalf("callback_address embed %q != disk %q", pins.Auth.BrowserConsent.CallbackAddress, fromDisk.Auth.BrowserConsent.CallbackAddress) + if config.Auth.BrowserConsent.CallbackAddress != "127.0.0.1:54321" { + t.Fatalf("auth.browser_consent.callback_address: got %q", config.Auth.BrowserConsent.CallbackAddress) } - - for _, u := range []string{pins.Auth.BaseURL, pins.Gateway.BaseURL, pins.Agent.RegisterUserAgentURL} { + for _, u := range []string{config.Auth.BaseURL, config.Gateway.BaseURL, config.Agent.RegisterUserAgentURL} { if !strings.HasPrefix(u, "https://") { - t.Fatalf("expected https pin, got %q", u) + t.Fatalf("expected https endpoint, got %q", u) } } - if pins.Auth.BrowserConsent.CallbackAddress == "" { - t.Fatal("expected non-empty callback address pin") - } } diff --git a/internal/configuration/config_test.go b/internal/configuration/config_test.go index 6974996..c154f98 100644 --- a/internal/configuration/config_test.go +++ b/internal/configuration/config_test.go @@ -1,11 +1,13 @@ package configuration import ( + "os" + "path/filepath" "testing" "time" -) -var testDefaultConfig = []byte("app:\n name: tollbit\nauth:\n base_url: https://oauth.tollbit.com\n retry_on_obo_required: true\n token_ttl_seconds: 0\n use_refresh_tokens: true\n browser_consent:\n callback_address: 127.0.0.1:54321\n timeout: 3m\n auto_open_browser: true\nagent:\n default_name: anonymous\n default_user_agent: \"\"\n register_user_agent_url: https://hack.tollbit.com/my-agents\ncredentials:\n storage_dir: __default__\ngateway:\n base_url: https://gateway.tollbit.com\n") + tollbitcli "github.com/tollbit/cli" +) func TestAssembleConfigurationUsesEmbeddedDefaults(t *testing.T) { config := assembleTestConfiguration(t, t.TempDir()) @@ -22,9 +24,24 @@ func TestAssembleConfigurationUsesEmbeddedDefaults(t *testing.T) { if !config.Auth.UseRefreshTokens { t.Fatal("expected refresh tokens enabled by default") } + if config.Runtime.EndUserProximity != RuntimeEndUserProximityAutoDetect { + t.Fatalf("expected auto-detect end-user proximity default, got %#v", config.Runtime) + } + if config.Auth.Consent.Strategy.Local != ConsentStrategyRedirect || config.Auth.Consent.Strategy.Remote != ConsentStrategyBrowserSelectIcon { + t.Fatalf("expected default consent strategy mapping, got %#v", config.Auth.Consent) + } + if got := ResolveConsentStrategy(config); got != ConsentStrategyBrowserSelectIcon { + t.Fatalf("expected auto-detect consent strategy %q, got %q", ConsentStrategyBrowserSelectIcon, got) + } if config.Credentials.StorageDir == "" || config.Credentials.StorageDir == "__default__" { t.Fatalf("expected resolved credentials storage dir, got %q", config.Credentials.StorageDir) } + if config.Runtime.StateDir == "" || config.Runtime.StateDir == "__default__" { + t.Fatalf("expected resolved runtime state dir, got %q", config.Runtime.StateDir) + } + if config.Runtime.StateDir != config.Credentials.StorageDir { + t.Fatalf("expected default runtime and credentials dirs to match, got runtime=%q credentials=%q", config.Runtime.StateDir, config.Credentials.StorageDir) + } } func TestAssembleConfigurationRequiresDefaultConfig(t *testing.T) { @@ -34,6 +51,44 @@ func TestAssembleConfigurationRequiresDefaultConfig(t *testing.T) { } } +func TestAssembleConfigurationAppliesCommonEnv(t *testing.T) { + t.Setenv("TOLLBIT_AGENT_DEFAULT_NAME", "env-agent") + t.Setenv("TOLLBIT_AUTH_BROWSER_CONSENT_TIMEOUT", "30s") + + config := assembleTestConfiguration(t, t.TempDir()) + + if config.Agent.DefaultName != "env-agent" { + t.Fatalf("expected env agent name, got %q", config.Agent.DefaultName) + } + if config.Auth.BrowserConsent.Timeout != 30*time.Second { + t.Fatalf("expected env browser timeout, got %s", config.Auth.BrowserConsent.Timeout) + } +} + +func TestIsConfigurableReportsCommonFields(t *testing.T) { + if !IsConfigurable("agent.default_name") { + t.Fatal("expected agent.default_name to be configurable") + } + if !IsConfigurable("runtime.end_user_proximity") || !IsConfigurable("runtime.state_dir") { + t.Fatal("expected runtime fields to be configurable") + } + if IsConfigurable("auth.consent.strategy.local") != IsDev { + t.Fatalf("expected auth.consent.strategy.local configurability to match IsDev=%v", IsDev) + } + if !IsConfigurable("auth.browser_consent.timeout") { + t.Fatal("expected auth.browser_consent.timeout to be configurable") + } + if !IsConfigurable("credentials.storage_dir") { + t.Fatal("expected credentials.storage_dir to be configurable") + } + if IsConfigurable("auth.base_url") != IsDev { + t.Fatalf("expected auth.base_url configurability to match IsDev=%v", IsDev) + } + if IsConfigurable("app.name") { + t.Fatal("expected untagged app.name to never be configurable") + } +} + func TestConfigWithOverridesAppliesAndValidates(t *testing.T) { config := assembleTestConfiguration(t, t.TempDir()) gatewayBaseURL := "https://gateway-flag.example.com" @@ -65,18 +120,88 @@ func TestConfigWithOverridesAppliesAndValidates(t *testing.T) { func TestConfigWithOverridesRejectsInvalidConfig(t *testing.T) { config := assembleTestConfiguration(t, t.TempDir()) blank := "" + invalidEndUserProximity := "invalid" - _, err := config.WithOverrides(OverrideOptions{AuthBaseURL: &blank}) - if err == nil { - t.Fatal("expected error") + tests := []struct { + name string + overrides OverrideOptions + }{ + { + name: "blank auth base URL", + overrides: OverrideOptions{AuthBaseURL: &blank}, + }, + { + name: "invalid end-user proximity value", + overrides: OverrideOptions{RuntimeEndUserProximity: &invalidEndUserProximity}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := config.WithOverrides(tt.overrides) + if err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestConfigWithOverridesAppliesRuntimeProximity(t *testing.T) { + config := assembleTestConfiguration(t, t.TempDir()) + endUserProximity := RuntimeEndUserProximityLocal + + got, err := config.WithOverrides(OverrideOptions{RuntimeEndUserProximity: &endUserProximity}) + if err != nil { + t.Fatal(err) + } + if got.Runtime.EndUserProximity != RuntimeEndUserProximityLocal { + t.Fatalf("expected runtime end-user proximity override, got %#v", got.Runtime) + } + if strategy := ResolveConsentStrategy(got); strategy != ConsentStrategyRedirect { + t.Fatalf("expected local consent strategy %q, got %q", ConsentStrategyRedirect, strategy) + } +} + +func TestShippedRemoteConsentStrategyIsAgentConfirmsIcons(t *testing.T) { + config, err := assembleConfiguration(tollbitcli.DefaultConfig, func() (string, error) { return t.TempDir(), nil }) + if err != nil { + t.Fatal(err) + } + if config.Auth.Consent.Strategy.Local != ConsentStrategyRedirect { + t.Fatalf("expected shipped local strategy redirect, got %q", config.Auth.Consent.Strategy.Local) + } + if config.Auth.Consent.Strategy.Remote != ConsentStrategyAgentConfirmsIcons { + t.Fatalf("expected shipped remote strategy agent_confirms_icons, got %q", config.Auth.Consent.Strategy.Remote) + } +} + +func TestValidateConsentStrategyAcceptsAgentConfirmsIcons(t *testing.T) { + config := assembleTestConfiguration(t, t.TempDir()) + config.Auth.Consent.Strategy.Local = ConsentStrategyAgentConfirmsIcons + config.Auth.Consent.Strategy.Remote = ConsentStrategyAgentConfirmsIcons + if err := validate(config); err != nil { + t.Fatalf("expected agent_confirms_icons to be valid: %v", err) + } + config.Auth.Consent.Strategy.Remote = "unknown" + if err := validate(config); err == nil { + t.Fatal("expected unknown consent strategy to be rejected") } } func assembleTestConfiguration(t *testing.T, wd string) Config { t.Helper() - config, err := assembleConfiguration(testDefaultConfig, func() (string, error) { return wd, nil }) + config, err := assembleConfiguration(readTestdata(t, "default-config.yaml"), func() (string, error) { return wd, nil }) if err != nil { t.Fatal(err) } return config } + +func readTestdata(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatal(err) + } + return b +} diff --git a/internal/configuration/env.go b/internal/configuration/env.go new file mode 100644 index 0000000..36f715a --- /dev/null +++ b/internal/configuration/env.go @@ -0,0 +1,59 @@ +package configuration + +import ( + "fmt" + "os" + "reflect" + "strconv" + "strings" + "time" +) + +func applyEnv(config *Config) error { + return walkConfigFields(reflect.ValueOf(config).Elem(), "", func(path string, value reflect.Value) error { + if !IsConfigurable(path) { + return nil + } + name := envName(path) + raw, ok := os.LookupEnv(name) + if !ok { + return nil + } + return setValueFromString(value, name, strings.TrimSpace(raw)) + }) +} + +func setValueFromString(value reflect.Value, name string, raw string) error { + if value.Type() == reflect.TypeOf(time.Duration(0)) { + parsed, err := time.ParseDuration(raw) + if err != nil { + return fmt.Errorf("read %s: %w", name, err) + } + value.SetInt(int64(parsed)) + return nil + } + switch value.Kind() { + case reflect.String: + value.SetString(raw) + case reflect.Bool: + parsed, err := strconv.ParseBool(raw) + if err != nil { + return fmt.Errorf("read %s: %w", name, err) + } + value.SetBool(parsed) + case reflect.Int32: + parsed, err := strconv.ParseInt(raw, 10, 32) + if err != nil { + return fmt.Errorf("read %s: %w", name, err) + } + value.SetInt(parsed) + default: + return fmt.Errorf("read %s: unsupported config type %s", name, value.Type()) + } + return nil +} + +func envName(path string) string { + name := strings.ToUpper(strings.ReplaceAll(path, ".", "_")) + return envPrefix + "_" + name +} diff --git a/internal/configuration/introspection.go b/internal/configuration/introspection.go new file mode 100644 index 0000000..2595a2e --- /dev/null +++ b/internal/configuration/introspection.go @@ -0,0 +1,115 @@ +package configuration + +import ( + "reflect" + "strings" + "sync" + "time" +) + +const ( + configurableDev = "dev" + configurableRelease = "release" +) + +var ( + configurablePathsOnce sync.Once + configurablePaths map[string]string +) + +// IsConfigurable reports whether a config path can be overridden by external +// sources in the current build mode. Paths use mapstructure names joined with +// dots, for example "auth.base_url" or "credentials.storage_dir". +// +// A field tagged configurable:"release" is configurable in all builds. A field +// tagged configurable:"dev" is configurable only in dev builds. Fields without +// a configurable tag are loaded from config files but cannot be changed through +// environment variables or global flags. +func IsConfigurable(path string) bool { + mode, ok := allConfigurablePaths()[path] + if !ok { + return false + } + return mode == configurableRelease || (mode == configurableDev && IsDev) +} + +// allConfigurablePaths returns every Config leaf field tagged with +// configurable. The result is cached because env loading and CLI flag +// registration both query this metadata during startup. +func allConfigurablePaths() map[string]string { + configurablePathsOnce.Do(func() { + paths := make(map[string]string) + _ = walkConfigType(reflect.TypeOf(Config{}), "", func(path string, field reflect.StructField) error { + mode := field.Tag.Get("configurable") + if mode != "" { + paths[path] = mode + } + return nil + }) + configurablePaths = paths + }) + return configurablePaths +} + +// walkConfigFields walks a concrete Config value and visits each leaf field +// with its dot-delimited config path. It recurses through nested config structs +// and treats time.Duration as a scalar leaf so callers can parse and assign +// duration values directly. +func walkConfigFields(value reflect.Value, prefix string, visit func(path string, value reflect.Value) error) error { + typeOfValue := value.Type() + for i := 0; i < value.NumField(); i++ { + field := typeOfValue.Field(i) + path := configPath(prefix, field) + if path == "" { + continue + } + fieldValue := value.Field(i) + if fieldValue.Kind() == reflect.Struct && fieldValue.Type() != reflect.TypeOf(time.Duration(0)) { + if err := walkConfigFields(fieldValue, path, visit); err != nil { + return err + } + continue + } + if err := visit(path, fieldValue); err != nil { + return err + } + } + return nil +} + +// walkConfigType walks the Config type and visits each leaf struct field with +// its dot-delimited config path. It is used to read mapstructure/configurable +// tags without needing a Config value. +func walkConfigType(typeOfValue reflect.Type, prefix string, visit func(path string, field reflect.StructField) error) error { + for i := 0; i < typeOfValue.NumField(); i++ { + field := typeOfValue.Field(i) + path := configPath(prefix, field) + if path == "" { + continue + } + if field.Type.Kind() == reflect.Struct && field.Type != reflect.TypeOf(time.Duration(0)) { + if err := walkConfigType(field.Type, path, visit); err != nil { + return err + } + continue + } + if err := visit(path, field); err != nil { + return err + } + } + return nil +} + +// configPath derives a dot-delimited config path segment from the field's +// mapstructure tag. Falling back to the lowercase Go field name keeps the +// helper usable for simple fields that do not need explicit mapstructure tags. +func configPath(prefix string, field reflect.StructField) string { + name := strings.Split(field.Tag.Get("mapstructure"), ",")[0] + if name == "" || name == "-" { + name = strings.ToLower(field.Name) + } + if prefix == "" { + return name + } + return prefix + "." + name +} diff --git a/internal/configuration/mode_dev.go b/internal/configuration/mode_dev.go new file mode 100644 index 0000000..1942470 --- /dev/null +++ b/internal/configuration/mode_dev.go @@ -0,0 +1,5 @@ +//go:build dev + +package configuration + +const IsDev = true diff --git a/internal/configuration/mode_release.go b/internal/configuration/mode_release.go new file mode 100644 index 0000000..8a011f4 --- /dev/null +++ b/internal/configuration/mode_release.go @@ -0,0 +1,5 @@ +//go:build !dev + +package configuration + +const IsDev = false diff --git a/internal/configuration/models.go b/internal/configuration/models.go index 245d155..f19d08c 100644 --- a/internal/configuration/models.go +++ b/internal/configuration/models.go @@ -4,6 +4,7 @@ import "time" type Config struct { App AppConfig + Runtime RuntimeConfig Auth AuthConfig Agent AgentConfig Credentials CredentialsConfig @@ -14,32 +15,47 @@ type AppConfig struct { Name string } +type RuntimeConfig struct { + EndUserProximity string `mapstructure:"end_user_proximity" configurable:"release"` + StateDir string `mapstructure:"state_dir" configurable:"release"` +} + type AuthConfig struct { - BaseURL string `mapstructure:"base_url"` - RetryOnOBORequired bool `mapstructure:"retry_on_obo_required"` - TokenTTLSeconds int32 `mapstructure:"token_ttl_seconds"` - UseRefreshTokens bool `mapstructure:"use_refresh_tokens"` + BaseURL string `mapstructure:"base_url" configurable:"dev"` + RetryOnOBORequired bool `mapstructure:"retry_on_obo_required" configurable:"release"` + TokenTTLSeconds int32 `mapstructure:"token_ttl_seconds" configurable:"release"` + UseRefreshTokens bool `mapstructure:"use_refresh_tokens" configurable:"release"` + Consent ConsentConfig `mapstructure:"consent"` BrowserConsent BrowserConsentConfig `mapstructure:"browser_consent"` } +type ConsentConfig struct { + Strategy ConsentStrategyConfig `mapstructure:"strategy"` +} + +type ConsentStrategyConfig struct { + Local string `mapstructure:"local" configurable:"dev"` + Remote string `mapstructure:"remote" configurable:"dev"` +} + type AgentConfig struct { - DefaultName string `mapstructure:"default_name"` - DefaultUserAgent string `mapstructure:"default_user_agent"` - RegisterUserAgentURL string `mapstructure:"register_user_agent_url"` + DefaultName string `mapstructure:"default_name" configurable:"release"` + DefaultUserAgent string `mapstructure:"default_user_agent" configurable:"release"` + RegisterUserAgentURL string `mapstructure:"register_user_agent_url" configurable:"dev"` } type CredentialsConfig struct { - StorageDir string `mapstructure:"storage_dir"` + StorageDir string `mapstructure:"storage_dir" configurable:"release"` } type GatewayConfig struct { - BaseURL string `mapstructure:"base_url"` + BaseURL string `mapstructure:"base_url" configurable:"dev"` } type BrowserConsentConfig struct { - CallbackAddress string `mapstructure:"callback_address"` - Timeout time.Duration `mapstructure:"timeout"` - AutoOpenBrowser bool `mapstructure:"auto_open_browser"` + CallbackAddress string `mapstructure:"callback_address" configurable:"dev"` + Timeout time.Duration `mapstructure:"timeout" configurable:"release"` + AutoOpenBrowser bool `mapstructure:"auto_open_browser" configurable:"release"` } type OverrideOptions struct { @@ -47,6 +63,8 @@ type OverrideOptions struct { AuthRetryOnOBORequired *bool AuthTokenTTLSeconds *int32 AuthUseRefreshTokens *bool + RuntimeEndUserProximity *string + RuntimeStateDir *string AuthBrowserConsentCallbackAddress *string AuthBrowserConsentTimeout *time.Duration AuthBrowserConsentAutoOpenBrowser *bool @@ -58,6 +76,12 @@ func (c Config) WithOverrides(opts OverrideOptions) (Config, error) { if opts.AuthBaseURL != nil { c.Auth.BaseURL = *opts.AuthBaseURL } + if opts.RuntimeEndUserProximity != nil { + c.Runtime.EndUserProximity = *opts.RuntimeEndUserProximity + } + if opts.RuntimeStateDir != nil { + c.Runtime.StateDir = *opts.RuntimeStateDir + } if opts.AuthRetryOnOBORequired != nil { c.Auth.RetryOnOBORequired = *opts.AuthRetryOnOBORequired } diff --git a/internal/configuration/platformdefaults/defaults.go b/internal/configuration/platformdefaults/defaults.go index 2ec198d..b16e158 100644 --- a/internal/configuration/platformdefaults/defaults.go +++ b/internal/configuration/platformdefaults/defaults.go @@ -8,7 +8,7 @@ import ( const DefaultSentinel = "__default__" -func CredentialsStorageDir(value string) (string, error) { +func StateDir(value string) (string, error) { value = strings.TrimSpace(value) if value != "" && value != DefaultSentinel { return value, nil @@ -19,3 +19,7 @@ func CredentialsStorageDir(value string) (string, error) { } return filepath.Join(home, ".config", "tollbit"), nil } + +func CredentialsStorageDir(value string) (string, error) { + return StateDir(value) +} diff --git a/internal/configuration/testdata/default-config.yaml b/internal/configuration/testdata/default-config.yaml new file mode 100644 index 0000000..f2fe61c --- /dev/null +++ b/internal/configuration/testdata/default-config.yaml @@ -0,0 +1,26 @@ +app: + name: tollbit +runtime: + end_user_proximity: auto-detect + state_dir: __default__ +auth: + base_url: https://oauth.tollbit.com + consent: + strategy: + local: redirect + remote: browser_select_icon + retry_on_obo_required: true + token_ttl_seconds: 0 + use_refresh_tokens: true + browser_consent: + callback_address: 127.0.0.1:54321 + timeout: 3m + auto_open_browser: true +agent: + default_name: anonymous + default_user_agent: "" + register_user_agent_url: https://hack.tollbit.com/my-agents +credentials: + storage_dir: __default__ +gateway: + base_url: https://gateway.tollbit.com diff --git a/internal/credentials/agenttoken/clear.go b/internal/credentials/agenttoken/clear.go index 3745a4c..f13fd7a 100644 --- a/internal/credentials/agenttoken/clear.go +++ b/internal/credentials/agenttoken/clear.go @@ -41,6 +41,9 @@ func (m *CredentialManager) ClearAuthTokens(ctx context.Context, force bool) err if err := m.clearRefreshCredential(ctx); err != nil { return err } + if err := m.ClearPendingConsent(ctx); err != nil { + return err + } if revokeErr != nil { // force: local credentials cleared, but revocation failed — signal it. return fmt.Errorf("%w: %w", ErrRevokeFailed, revokeErr) diff --git a/internal/credentials/agenttoken/get_agent_token.go b/internal/credentials/agenttoken/get_agent_token.go index 0fa0e95..756a231 100644 --- a/internal/credentials/agenttoken/get_agent_token.go +++ b/internal/credentials/agenttoken/get_agent_token.go @@ -1,7 +1,9 @@ package agenttoken import ( + "context" "errors" + "fmt" "github.com/rs/zerolog" "github.com/tollbit/cli/internal/agentauth" @@ -87,19 +89,16 @@ func (m *CredentialManager) GetAgentToken(inv agentauth.Invocation, identity aut if opts.requireOBO { resp, err := m.oboAuthorizer.AuthorizeOBO(inv, identity, token) if err != nil { + var pending agentauth.AuthorizationPendingError + if errors.As(err, &pending) { + if saveErr := m.savePendingConsent(ctx, pending.Pending); saveErr != nil { + return agent.Token{}, fmt.Errorf("save pending authorization: %w", saveErr) + } + } return agent.Token{}, err } - oboToken := agent.Token{RawToken: resp.Token} - if err := oboToken.Validate(); err != nil { - return agent.Token{}, err - } - // Persist the OBO token and clear any stale refresh token unless this response includes a rotated one. - creds := credentials{AgentToken: oboToken} - if opts.useRefreshTokens { - creds.RefreshToken = valueOrEmpty(resp.RefreshToken) - creds.RefreshTokenExpiresAt = valueOrEmpty(resp.RefreshTokenExpiresAt) - } - if err := m.saveCredentials(ctx, creds); err != nil { + oboToken, err := m.saveAgentTokenResponse(ctx, resp, opts.useRefreshTokens) + if err != nil { return agent.Token{}, err } return oboToken, nil @@ -107,6 +106,23 @@ func (m *CredentialManager) GetAgentToken(inv agentauth.Invocation, identity aut return token, nil } +func (m *CredentialManager) saveAgentTokenResponse(ctx context.Context, resp auth.AgentTokenResponse, useRefreshTokens bool) (agent.Token, error) { + token := agent.Token{RawToken: resp.Token} + if err := token.Validate(); err != nil { + return agent.Token{}, err + } + // Persist the OBO token and clear any stale refresh token unless this response includes a rotated one. + creds := credentials{AgentToken: token} + if useRefreshTokens { + creds.RefreshToken = valueOrEmpty(resp.RefreshToken) + creds.RefreshTokenExpiresAt = valueOrEmpty(resp.RefreshTokenExpiresAt) + } + if err := m.saveCredentials(ctx, creds); err != nil { + return agent.Token{}, err + } + return token, nil +} + // refreshOBOTokenViaRefreshToken tries the non-interactive refresh-token path. // The caller only invokes this after confirming the cached token is expired and has OBO claims. func (m *CredentialManager) refreshOBOTokenViaRefreshToken(inv agentauth.Invocation, identity auth.AgentIdentity) (agent.Token, bool) { diff --git a/internal/credentials/agenttoken/manager.go b/internal/credentials/agenttoken/manager.go index 2a059e9..8dded85 100644 --- a/internal/credentials/agenttoken/manager.go +++ b/internal/credentials/agenttoken/manager.go @@ -13,6 +13,7 @@ const ( identityFilename = "agent-identity.json" tokenFilename = "agent-token.jwt" refreshFilename = "refresh-token.json" + pendingFilename = "pending-auth.json" ) type ( @@ -28,6 +29,13 @@ type ( UserAgent *string } + RefreshTokenStatus struct { + Present bool `json:"present"` + ExpiresAt string `json:"expires_at,omitempty"` + Expired bool `json:"expired,omitempty"` + Error string `json:"error,omitempty"` + } + CredentialManagerConfig struct { Path string DefaultIdentity auth.AgentIdentity @@ -43,6 +51,7 @@ type ( identityPath string tokenPath string refreshPath string + pendingPath string defaultIdentity auth.AgentIdentity tokenOptions auth.AgentTokenOptions authClient *auth.Client @@ -88,6 +97,7 @@ func New(cfg CredentialManagerConfig) (*CredentialManager, error) { identityPath: filepath.Join(dir, identityFilename), tokenPath: filepath.Join(dir, tokenFilename), refreshPath: filepath.Join(dir, refreshFilename), + pendingPath: filepath.Join(dir, pendingFilename), defaultIdentity: defaultIdentity, tokenOptions: cfg.TokenOptions, authClient: cfg.AuthClient, @@ -95,3 +105,11 @@ func New(cfg CredentialManagerConfig) (*CredentialManager, error) { useRefreshTokens: cfg.UseRefreshTokens, }, nil } + +func (m *CredentialManager) SupportsOBORetry() bool { + return m.oboAuthorizer.SupportsOBORetry() +} + +func (m *CredentialManager) AutoRefreshEnabled() bool { + return m.useRefreshTokens +} diff --git a/internal/credentials/agenttoken/manager_test.go b/internal/credentials/agenttoken/manager_test.go index af266a7..dcf2c06 100644 --- a/internal/credentials/agenttoken/manager_test.go +++ b/internal/credentials/agenttoken/manager_test.go @@ -1072,6 +1072,11 @@ type fakeOBOAuthorizer struct { token string baseToken agent.Token callCount int + noRetry bool +} + +func (a *fakeOBOAuthorizer) SupportsOBORetry() bool { + return !a.noRetry } func (a *fakeOBOAuthorizer) AuthorizeOBO(inv agentauth.Invocation, identity auth.AgentIdentity, baseToken agent.Token) (auth.AgentTokenResponse, error) { @@ -1080,6 +1085,10 @@ func (a *fakeOBOAuthorizer) AuthorizeOBO(inv agentauth.Invocation, identity auth return auth.AgentTokenResponse{Token: a.token}, nil } +func (a *fakeOBOAuthorizer) CompleteDetached(inv agentauth.Invocation, pending agentauth.PendingConsent, input agentauth.CompleteDetachedInput) (auth.AgentTokenResponse, error) { + return auth.AgentTokenResponse{}, errors.New("not implemented") +} + func testAgentIdentity() auth.AgentIdentity { return auth.AgentIdentity{Name: "agent-test", UserAgent: "agent-test/0.1"} } @@ -1128,3 +1137,138 @@ func encodeJSONSegment(t *testing.T, value any) string { } return base64.RawURLEncoding.EncodeToString(b) } + +func TestPendingConsentCredentialRoundTrip(t *testing.T) { + dir := t.TempDir() + mgr := newTestManager(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + pending := agentauth.PendingConsent{ + Method: agentauth.ConsentMethodBrowserSelectIcon, + ChallengeID: "ach_123", + AgentIdentity: testAgentIdentity(), + BaseToken: testJWT(t, validClaims()), + CodeVerifier: "verifier-1", + CreatedAt: time.Now().UTC(), + ExpiresAt: "2026-07-20T12:00:00Z", + } + + if err := mgr.savePendingConsent(context.Background(), pending); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, pendingFilename)) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("expected pending credential mode 0600, got %#o", got) + } + got, exists, err := mgr.GetPendingConsent(context.Background()) + if err != nil { + t.Fatal(err) + } + if !exists || got.ChallengeID != pending.ChallengeID || got.AgentIdentity.Name != pending.AgentIdentity.Name || got.CodeVerifier != pending.CodeVerifier { + t.Fatalf("unexpected pending credential: exists=%v got=%#v", exists, got) + } + if err := mgr.ClearPendingConsent(context.Background()); err != nil { + t.Fatal(err) + } + if _, exists, err := mgr.GetPendingConsent(context.Background()); err != nil || exists { + t.Fatalf("expected pending credential cleared, exists=%v err=%v", exists, err) + } +} + +func TestCompletePendingConsentSavesTokenIdentityAndClearsPending(t *testing.T) { + dir := t.TempDir() + mgr := newTestManager(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + pending := agentauth.PendingConsent{ + Method: agentauth.ConsentMethodBrowserSelectIcon, + ChallengeID: "ach_123", + AgentIdentity: testAgentIdentity(), + BaseToken: testJWT(t, validClaims()), + CodeVerifier: "verifier-1", + UseRefreshTokens: true, + CreatedAt: time.Now().UTC(), + ExpiresAt: "2026-07-20T12:00:00Z", + } + if err := mgr.savePendingConsent(context.Background(), pending); err != nil { + t.Fatal(err) + } + refreshToken := "refresh-token" + refreshExpiresAt := "2026-07-20T13:00:00Z" + resp := auth.AgentTokenResponse{ + Token: testJWT(t, validClaims(func(claims agent.Claims) agent.Claims { + claims.OBO = &agent.OBOClaims{Ver: 1, Source: "consent", User: "usr_123", Org: "org_456"} + return claims + })), + RefreshToken: &refreshToken, + RefreshTokenExpiresAt: &refreshExpiresAt, + } + + token, err := mgr.CompletePendingConsent(context.Background(), pending, resp) + if err != nil { + t.Fatal(err) + } + if token.RawToken != resp.Token { + t.Fatalf("expected saved token, got %q", token.RawToken) + } + identity, exists, err := mgr.GetStoredIdentity(context.Background()) + if err != nil { + t.Fatal(err) + } + if !exists || identity.Name != pending.AgentIdentity.Name || identity.UserAgent != pending.AgentIdentity.UserAgent { + t.Fatalf("expected pending identity saved, exists=%v identity=%#v", exists, identity) + } + if _, exists, err := mgr.GetPendingConsent(context.Background()); err != nil || exists { + t.Fatalf("expected pending credential cleared, exists=%v err=%v", exists, err) + } + refresh, exists, err := mgr.readRefreshCredential(context.Background()) + if err != nil { + t.Fatal(err) + } + if !exists || refresh.RefreshToken != refreshToken || refresh.RefreshTokenExpiresAt != refreshExpiresAt { + t.Fatalf("expected refresh credential saved, exists=%v refresh=%#v", exists, refresh) + } +} + +func TestGetAgentTokenSavesPendingConsentOnAuthorizationPending(t *testing.T) { + dir := t.TempDir() + baseToken := testJWT(t, validClaims()) + pending := agentauth.PendingConsent{ + Method: agentauth.ConsentMethodBrowserSelectIcon, + ChallengeID: "ach_pending", + AgentIdentity: testAgentIdentity(), + BaseToken: baseToken, + CodeVerifier: "verifier-1", + CreatedAt: time.Now().UTC(), + } + authorizer := &pendingOBOAuthorizer{pending: pending} + mgr := newTestManagerWithConfig(t, dir, CredentialManagerConfig{OBOAuthorizer: authorizer}, testMintHandler(t, baseToken)) + + _, err := mgr.GetAgentToken(testInvocation{}, testAgentIdentity(), WithOBO()) + var pendingErr agentauth.AuthorizationPendingError + if !errors.As(err, &pendingErr) { + t.Fatalf("expected authorization pending error, got %v", err) + } + got, exists, err := mgr.GetPendingConsent(context.Background()) + if err != nil { + t.Fatal(err) + } + if !exists || got.ChallengeID != "ach_pending" { + t.Fatalf("expected pending consent saved, exists=%v got=%#v", exists, got) + } +} + +type pendingOBOAuthorizer struct { + pending agentauth.PendingConsent +} + +func (a *pendingOBOAuthorizer) SupportsOBORetry() bool { + return false +} + +func (a *pendingOBOAuthorizer) AuthorizeOBO(inv agentauth.Invocation, identity auth.AgentIdentity, baseToken agent.Token) (auth.AgentTokenResponse, error) { + return auth.AgentTokenResponse{}, agentauth.AuthorizationPendingError{Pending: a.pending} +} + +func (a *pendingOBOAuthorizer) CompleteDetached(inv agentauth.Invocation, pending agentauth.PendingConsent, input agentauth.CompleteDetachedInput) (auth.AgentTokenResponse, error) { + return auth.AgentTokenResponse{}, errors.New("not implemented") +} diff --git a/internal/credentials/agenttoken/obo_requirements.go b/internal/credentials/agenttoken/obo_requirements.go index 4eb0356..67f9ae1 100644 --- a/internal/credentials/agenttoken/obo_requirements.go +++ b/internal/credentials/agenttoken/obo_requirements.go @@ -9,6 +9,17 @@ import ( "github.com/tollbit/cli/internal/tokens/agent" ) +type ExplicitLoginRequiredError struct { + Reason string +} + +func (e ExplicitLoginRequiredError) Error() string { + if e.Reason != "" { + return e.Reason + } + return "explicit auth login is required" +} + func WithOBORetry[T any](inv agentauth.Invocation, mgr *CredentialManager, identity auth.AgentIdentity, call func(agent.Token) (T, error)) (T, error) { token, err := mgr.GetAgentToken(inv, identity) if err != nil { @@ -19,6 +30,9 @@ func WithOBORetry[T any](inv agentauth.Invocation, mgr *CredentialManager, ident if err == nil || !IsOBORequired(err) { return out, err } + if !mgr.SupportsOBORetry() { + return out, ExplicitLoginRequiredError{Reason: "operation requires user or organization authorization; run `tollbit auth login`, then retry"} + } oboToken, err := mgr.GetAgentToken(inv, identity, WithOBO()) if err != nil { diff --git a/internal/credentials/agenttoken/obo_requirements_test.go b/internal/credentials/agenttoken/obo_requirements_test.go index 5ecc0d9..ae08aaa 100644 --- a/internal/credentials/agenttoken/obo_requirements_test.go +++ b/internal/credentials/agenttoken/obo_requirements_test.go @@ -74,6 +74,30 @@ func TestWithOBORetryRetriesOBORequiredWithOBOToken(t *testing.T) { } } +func TestWithOBORetryRequiresExplicitLoginWhenAuthorizerDoesNotSupportRetry(t *testing.T) { + baseToken := testJWT(t, validClaims()) + authorizer := &retryOBOAuthorizer{noRetry: true} + mgr := newTestManagerWithConfig(t, t.TempDir(), CredentialManagerConfig{OBOAuthorizer: authorizer}, testMintHandler(t, baseToken)) + + var calls int + _, err := WithOBORetry(testInvocation{}, mgr, testAgentIdentity(), func(token agent.Token) (string, error) { + calls++ + code := problemjson.ErrorCodeOboRequired + return "", problemjson.Problem{Code: &code} + }) + + var explicit ExplicitLoginRequiredError + if !errors.As(err, &explicit) { + t.Fatalf("expected explicit login required error, got %v", err) + } + if err.Error() != "operation requires user or organization authorization; run `tollbit auth login`, then retry" { + t.Fatalf("unexpected error message: %v", err) + } + if calls != 1 { + t.Fatalf("expected no retry call, got %d calls", calls) + } +} + func TestWithOBORetryReturnsOBOAuthorizationError(t *testing.T) { baseToken := testJWT(t, validClaims()) wantErr := errors.New("consent failed") @@ -90,8 +114,13 @@ func TestWithOBORetryReturnsOBOAuthorizationError(t *testing.T) { } type retryOBOAuthorizer struct { - token string - err error + token string + err error + noRetry bool +} + +func (a *retryOBOAuthorizer) SupportsOBORetry() bool { + return !a.noRetry } func (a *retryOBOAuthorizer) AuthorizeOBO(inv agentauth.Invocation, identity auth.AgentIdentity, baseToken agent.Token) (auth.AgentTokenResponse, error) { @@ -101,6 +130,10 @@ func (a *retryOBOAuthorizer) AuthorizeOBO(inv agentauth.Invocation, identity aut return auth.AgentTokenResponse{Token: a.token}, nil } +func (a *retryOBOAuthorizer) CompleteDetached(inv agentauth.Invocation, pending agentauth.PendingConsent, input agentauth.CompleteDetachedInput) (auth.AgentTokenResponse, error) { + return auth.AgentTokenResponse{}, errors.New("not implemented") +} + func testMintHandler(t *testing.T, token string) http.HandlerFunc { t.Helper() return func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/credentials/agenttoken/pending.go b/internal/credentials/agenttoken/pending.go new file mode 100644 index 0000000..64b4c46 --- /dev/null +++ b/internal/credentials/agenttoken/pending.go @@ -0,0 +1,81 @@ +package agenttoken + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/tollbit/cli/internal/agentauth" + "github.com/tollbit/cli/internal/client/auth" + "github.com/tollbit/cli/internal/tokens/agent" +) + +func (m *CredentialManager) CompletePendingConsent(ctx context.Context, pending agentauth.PendingConsent, resp auth.AgentTokenResponse) (agent.Token, error) { + token, err := m.saveAgentTokenResponse(ctx, resp, pending.UseRefreshTokens) + if err != nil { + return agent.Token{}, err + } + if err := m.WriteIdentity(ctx, pending.AgentIdentity); err != nil { + return agent.Token{}, err + } + if err := m.ClearPendingConsent(ctx); err != nil { + return agent.Token{}, err + } + return token, nil +} + +func (m *CredentialManager) savePendingConsent(ctx context.Context, pending agentauth.PendingConsent) error { + if strings.TrimSpace(pending.ChallengeID) == "" { + return fmt.Errorf("pending consent challenge id is required") + } + if strings.TrimSpace(pending.AgentIdentity.Name) == "" { + return fmt.Errorf("pending consent agent identity is required") + } + if strings.TrimSpace(pending.BaseToken) == "" { + return fmt.Errorf("pending consent base token is required") + } + if strings.TrimSpace(pending.CodeVerifier) == "" { + return fmt.Errorf("pending consent code verifier is required") + } + if pending.Method == agentauth.ConsentMethodAgentConfirmsIcons && len(pending.IconNames) == 0 { + return fmt.Errorf("pending consent icon names are required") + } + return m.writeJSON(ctx, m.pendingPath, pending) +} + +func (m *CredentialManager) GetPendingConsent(ctx context.Context) (agentauth.PendingConsent, bool, error) { + if err := ctx.Err(); err != nil { + return agentauth.PendingConsent{}, false, err + } + raw, err := os.ReadFile(m.pendingPath) + if os.IsNotExist(err) { + return agentauth.PendingConsent{}, false, nil + } + if err != nil { + return agentauth.PendingConsent{}, false, fmt.Errorf("read pending auth credential: %w", err) + } + var pending agentauth.PendingConsent + if err := json.Unmarshal(raw, &pending); err != nil { + return agentauth.PendingConsent{}, false, fmt.Errorf("parse pending auth credential: %w", err) + } + if strings.TrimSpace(pending.ChallengeID) == "" { + return agentauth.PendingConsent{}, false, fmt.Errorf("pending auth credential missing challenge id") + } + return pending, true, nil +} + +func (m *CredentialManager) ClearPendingConsent(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + err := os.Remove(m.pendingPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("clear pending auth credential: %w", err) + } + return nil +} diff --git a/internal/credentials/agenttoken/refresh_status.go b/internal/credentials/agenttoken/refresh_status.go new file mode 100644 index 0000000..c5b2f81 --- /dev/null +++ b/internal/credentials/agenttoken/refresh_status.go @@ -0,0 +1,18 @@ +package agenttoken + +import "context" + +func (m *CredentialManager) RefreshTokenStatus(ctx context.Context) RefreshTokenStatus { + refresh, exists, err := m.readRefreshCredential(ctx) + if err != nil { + return RefreshTokenStatus{Error: err.Error()} + } + if !exists { + return RefreshTokenStatus{Present: false} + } + expired, err := refresh.expired() + if err != nil { + return RefreshTokenStatus{Present: true, ExpiresAt: refresh.RefreshTokenExpiresAt, Expired: true, Error: err.Error()} + } + return RefreshTokenStatus{Present: true, ExpiresAt: refresh.RefreshTokenExpiresAt, Expired: expired} +} diff --git a/internal/errorsx/problemjson/problem.go b/internal/errorsx/problemjson/problem.go index ec5d857..08595a7 100644 --- a/internal/errorsx/problemjson/problem.go +++ b/internal/errorsx/problemjson/problem.go @@ -19,6 +19,20 @@ const ( // ErrorCodeCLIUpdateRequired means the backend rejected this CLI version as // below the minimum supported version; the user must update to continue. ErrorCodeCLIUpdateRequired ErrorCode = "cli_update_required" + // ErrorCodeAuthorizationPending means a detached consent challenge has not + // been completed by the end user yet. + ErrorCodeAuthorizationPending ErrorCode = "authorization_pending" + // ErrorCodeAccessDenied means the end user denied the consent challenge. + ErrorCodeAccessDenied ErrorCode = "access_denied" + // ErrorCodeExpiredToken means the consent challenge expired before it was + // completed. + ErrorCodeExpiredToken ErrorCode = "expired_token" + // ErrorCodeInvalidGrant means the consent challenge can no longer be + // redeemed. + ErrorCodeInvalidGrant ErrorCode = "invalid_grant" + // ErrorCodeUnrecognizedIcon means a submitted icon name did not match the + // icon manifest; no consent attempt is consumed. + ErrorCodeUnrecognizedIcon ErrorCode = "unrecognized_icon" ) // Problem is the CLI-local representation of the ProblemJSON shape returned by diff --git a/skill/tollbit-cli/SKILL.md b/skill/tollbit-cli/SKILL.md index cbc2c63..93ccc2f 100644 --- a/skill/tollbit-cli/SKILL.md +++ b/skill/tollbit-cli/SKILL.md @@ -62,10 +62,16 @@ tollbit auth status tollbit auth set --name my-agent --user-agent MyAgent-User ``` +End-user proximity describes where the agent environment running the CLI is located relative to the end user's browser. The CLI uses that context to present either the local or the detached (remote) authorization flow. Follow the flow the CLI presents. + +{{ .AuthInstructions }} + +If `auth login` or `auth complete` reports that authorization is still pending, it exits with code 3 and keeps the pending authorization. Wait for the end user to finish and run `auth complete` again (`auth complete` takes: {{ .AuthCompleteInputs }}). After authorization succeeds, retry the command that required user/org authorization. Use `tollbit runtime status` to inspect the current setting. When the agent environment is known, prefer `tollbit runtime set --end-user-proximity local|remote` so future commands use the right flow automatically. Use `--end-user-proximity local|remote` only for a one-off override. + ## For automation - Prefer `--json` for machine-readable output on `search`, `content pricing`, `content fetch`, and `auth status`. -- **Exit codes:** `0` success · `1` runtime error · `2` usage error. `auth status --check` → `0` valid · `1` invalid/expired · `2` missing. Before paid `fetch`, prefer `auth status --check` (or `auth status --json`) so you fail fast instead of hanging on interactive consent. +- **Exit codes:** `0` success · `1` runtime error · `2` usage error · `3` authorization pending (`auth login` / `auth complete` in a detached flow). `auth status --check` → `0` valid · `1` invalid/expired · `2` missing. Before paid `fetch`, prefer `auth status --check` (or `auth status --json`) so you fail fast instead of hanging on interactive consent. - **Streams:** stdout carries data only; prompts, spinners, next-step hints, and errors go to stderr. Parse stdout for success data; treat non-zero exit as failure and read stderr when diagnosing. - **Non-interactive fetch:** never call `content fetch` without `--confirm`. Pass `--rate-index N` when multiple rates exist (required with `--json` in that case). Every fetch still charges. - **Licensable results:** only **Programmatic** results can be priced and fetched; use `--programmatic-only` or skip Enterprise hits. @@ -73,3 +79,5 @@ tollbit auth set --name my-agent --user-agent MyAgent-User Install this skill: `tollbit guide --install `. Compare frontmatter `version` with `tollbit version` when updating. + +{{ .AuthRenderedFooter }} diff --git a/tb-cli.config.yaml b/tb-cli.config.yaml index fb34188..e6b9b23 100644 --- a/tb-cli.config.yaml +++ b/tb-cli.config.yaml @@ -1,7 +1,14 @@ app: name: tollbit +runtime: + end_user_proximity: auto-detect + state_dir: __default__ auth: base_url: https://oauth.tollbit.com + consent: + strategy: + local: redirect + remote: agent_confirms_icons retry_on_obo_required: true token_ttl_seconds: 0 use_refresh_tokens: true