diff --git a/shared/auth/auth.go b/shared/auth/auth.go index a287f680..faba6255 100644 --- a/shared/auth/auth.go +++ b/shared/auth/auth.go @@ -13,15 +13,18 @@ const ( // AuthMethodBearer is the authentication method for service accounts with scoped API tokens. AuthMethodBearer = "bearer" + + // AuthMethodProxy sends no Authorization header and relies on a local or upstream proxy. + AuthMethodProxy = "proxy" ) // ErrInvalidAuthMethod is returned when an unrecognized auth method is provided. -var ErrInvalidAuthMethod = errors.New("invalid auth method: must be \"basic\" or \"bearer\"") +var ErrInvalidAuthMethod = errors.New("invalid auth method: must be \"basic\", \"bearer\", or \"proxy\"") // ValidateAuthMethod returns nil if method is a recognized auth method, or ErrInvalidAuthMethod otherwise. func ValidateAuthMethod(method string) error { switch method { - case AuthMethodBasic, AuthMethodBearer: + case AuthMethodBasic, AuthMethodBearer, AuthMethodProxy: return nil default: return fmt.Errorf("%w: got %q", ErrInvalidAuthMethod, method) diff --git a/shared/auth/auth_test.go b/shared/auth/auth_test.go index a8018337..d731ed9e 100644 --- a/shared/auth/auth_test.go +++ b/shared/auth/auth_test.go @@ -114,6 +114,7 @@ func TestValidateAuthMethod(t *testing.T) { }{ {name: "basic is valid", method: "basic", wantErr: false}, {name: "bearer is valid", method: "bearer", wantErr: false}, + {name: "proxy is valid", method: "proxy", wantErr: false}, {name: "empty string is invalid", method: "", wantErr: true}, {name: "capitalized Bearer is invalid", method: "Bearer", wantErr: true}, {name: "unknown method is invalid", method: "oauth", wantErr: true}, @@ -147,4 +148,7 @@ func TestAuthMethodConstants(t *testing.T) { if AuthMethodBearer != "bearer" { t.Errorf("AuthMethodBearer = %q, want %q", AuthMethodBearer, "bearer") } + if AuthMethodProxy != "proxy" { + t.Errorf("AuthMethodProxy = %q, want %q", AuthMethodProxy, "proxy") + } } diff --git a/shared/client/client.go b/shared/client/client.go index 084e81f6..a78f9417 100644 --- a/shared/client/client.go +++ b/shared/client/client.go @@ -44,6 +44,7 @@ func New(baseURL, email, apiToken string, opts *Options) *Client { var verbose bool var verboseOut io.Writer = os.Stderr var authHeader string + var skipAuthHeader bool if opts != nil { timeout = opts.timeoutOrDefault() @@ -52,9 +53,10 @@ func New(baseURL, email, apiToken string, opts *Options) *Client { verboseOut = opts.VerboseOut } authHeader = opts.AuthHeader + skipAuthHeader = opts.SkipAuthHeader } - if authHeader == "" { + if authHeader == "" && !skipAuthHeader { authHeader = auth.BasicAuthHeader(email, apiToken) } @@ -106,7 +108,9 @@ func (c *Client) Do(ctx context.Context, method, path string, body any) ([]byte, } // Set headers - req.Header.Set("Authorization", c.AuthHeader) + if c.AuthHeader != "" { + req.Header.Set("Authorization", c.AuthHeader) + } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") diff --git a/shared/client/client_test.go b/shared/client/client_test.go index 4dc604e7..fabacb2b 100644 --- a/shared/client/client_test.go +++ b/shared/client/client_test.go @@ -555,6 +555,27 @@ func TestNew_AuthHeaderOverride(t *testing.T) { } }) + t.Run("SkipAuthHeader sends no Authorization header", func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if auth := r.Header.Get("Authorization"); auth != "" { + t.Errorf("Authorization = %v, want empty", auth) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + c := New(server.URL, "", "", &Options{SkipAuthHeader: true}) + if c.AuthHeader != "" { + t.Errorf("AuthHeader = %v, want empty", c.AuthHeader) + } + + if _, err := c.Get(context.Background(), "/api/test"); err != nil { + t.Fatalf("Get() error = %v", err) + } + }) + t.Run("nil options uses Basic auth", func(t *testing.T) { t.Parallel() c := New("https://example.atlassian.net", "user@example.com", "token", nil) @@ -565,6 +586,24 @@ func TestNew_AuthHeaderOverride(t *testing.T) { }) } +func TestGatewayBaseURLFromEnv(t *testing.T) { + t.Setenv("JIRA_GATEWAY_BASE_URL", "") + t.Setenv("ATLASSIAN_GATEWAY_BASE_URL", "") + if got := GatewayBaseURLFromEnv("JIRA_GATEWAY_BASE_URL"); got != GatewayBaseURL { + t.Fatalf("default gateway = %q, want %q", got, GatewayBaseURL) + } + + t.Setenv("ATLASSIAN_GATEWAY_BASE_URL", "https://shared.example/") + if got := GatewayBaseURLFromEnv("JIRA_GATEWAY_BASE_URL"); got != "https://shared.example" { + t.Fatalf("shared gateway = %q", got) + } + + t.Setenv("JIRA_GATEWAY_BASE_URL", "https://jira.example/") + if got := GatewayBaseURLFromEnv("JIRA_GATEWAY_BASE_URL"); got != "https://jira.example" { + t.Fatalf("tool gateway = %q", got) + } +} + func TestOptions_timeoutOrDefault(t *testing.T) { t.Parallel() t.Run("nil options", func(t *testing.T) { diff --git a/shared/client/options.go b/shared/client/options.go index e28ec8c4..c988396d 100644 --- a/shared/client/options.go +++ b/shared/client/options.go @@ -2,6 +2,8 @@ package client import ( "io" + "os" + "strings" "time" ) @@ -12,6 +14,21 @@ const DefaultTimeout = 60 * time.Second // with scoped API tokens (service accounts). const GatewayBaseURL = "https://api.atlassian.com" +// GatewayBaseURLFromEnv returns a gateway base URL using tool-specific +// precedence, then the shared ATLASSIAN_GATEWAY_BASE_URL override, then +// the Atlassian Cloud gateway default. +func GatewayBaseURLFromEnv(primaryEnv string) string { + for _, name := range []string{primaryEnv, "ATLASSIAN_GATEWAY_BASE_URL"} { + if name == "" { + continue + } + if v := strings.TrimRight(strings.TrimSpace(os.Getenv(name)), "/"); v != "" { + return v + } + } + return GatewayBaseURL +} + // Options configures client behavior. type Options struct { // Timeout for HTTP requests. Defaults to 60 seconds if not set. @@ -27,6 +44,10 @@ type Options struct { // Use auth.BearerAuthHeader() for service accounts with scoped tokens. // When empty, New() computes BasicAuthHeader(email, apiToken) as before. AuthHeader string + + // SkipAuthHeader suppresses the Authorization header entirely. + // Use this for proxy auth, where a trusted proxy injects credentials. + SkipAuthHeader bool } // timeoutOrDefault returns the configured timeout or the default. diff --git a/shared/credstore/conndivergence_test.go b/shared/credstore/conndivergence_test.go index 228528ec..d1cd4e6b 100644 --- a/shared/credstore/conndivergence_test.go +++ b/shared/credstore/conndivergence_test.go @@ -123,6 +123,18 @@ func TestDetectConnDivergence(t *testing.T) { testutil.Equal(t, basic, got.AuthMethod) }) + t.Run("explicit proxy is preserved without email", func(t *testing.T) { + t.Parallel() + got, conf := DetectConnDivergence([]NamedConn{ + nc("shared config", "default", "/c.yml", ConnProfile{ + URL: "http://127.0.0.1:8080/atlassian", AuthMethod: "proxy", + }), + }) + testutil.Equal(t, 0, len(conf)) + testutil.Equal(t, "proxy", got.AuthMethod) + testutil.Equal(t, "http://127.0.0.1:8080/atlassian", got.URL) + }) + t.Run("all-empty source ignored", func(t *testing.T) { t.Parallel() got, conf := DetectConnDivergence([]NamedConn{ diff --git a/shared/credstore/credstore.go b/shared/credstore/credstore.go index 0c3fae48..f80d8b0f 100644 --- a/shared/credstore/credstore.go +++ b/shared/credstore/credstore.go @@ -199,8 +199,9 @@ func (s *Store) ResolveWithSource(_, field string) (string, Source) { // complete enough to authenticate once a token is supplied. The api_token // is no longer part of this store (it lives in the keyring), so callers // must compose this with keyring.HasToken for full readiness. Basic -// requires url + email; bearer requires url + cloud_id. Empty auth_method -// defaults to basic, matching the rest of the codebase. +// requires url + email; bearer requires url + cloud_id; proxy requires +// only url because authentication is delegated to the proxy. Empty +// auth_method defaults to basic, matching the rest of the codebase. func (s *Store) HasUsableConfig(tool string) bool { r := s.Resolve(tool) method := r.AuthMethod @@ -210,6 +211,8 @@ func (s *Store) HasUsableConfig(tool string) bool { switch method { case auth.AuthMethodBearer: return r.URL != "" && r.CloudID != "" + case auth.AuthMethodProxy: + return r.URL != "" case auth.AuthMethodBasic: return r.URL != "" && r.Email != "" default: diff --git a/shared/credstore/credstore_test.go b/shared/credstore/credstore_test.go index 87aecbea..4d089037 100644 --- a/shared/credstore/credstore_test.go +++ b/shared/credstore/credstore_test.go @@ -224,6 +224,22 @@ func TestHasUsableConfig(t *testing.T) { tool: ToolJTK, want: true, }, + { + name: "proxy needs only URL", + s: &Store{Default: Section{ + URL: "http://127.0.0.1:8080/atlassian", AuthMethod: auth.AuthMethodProxy, + }}, + tool: ToolCFL, + want: true, + }, + { + name: "proxy missing URL", + s: &Store{Default: Section{ + AuthMethod: auth.AuthMethodProxy, + }}, + tool: ToolJTK, + want: false, + }, { name: "empty store", s: &Store{}, diff --git a/shared/url/url.go b/shared/url/url.go index 48545c86..94b93953 100644 --- a/shared/url/url.go +++ b/shared/url/url.go @@ -1,7 +1,11 @@ // Package url provides URL normalization utilities for Atlassian CLI tools. package url -import "strings" +import ( + "net/netip" + neturl "net/url" + "strings" +) // NormalizeURL ensures the URL has an https scheme and no trailing slashes. // If the URL is empty, it returns an empty string. @@ -36,3 +40,19 @@ func HasScheme(u string) bool { func TrimTrailingSlashes(u string) string { return strings.TrimRight(u, "/") } + +// IsLoopbackHTTP reports whether u is an http:// URL whose host is localhost +// or a loopback IP address. It is intentionally narrow so CLIs can allow local +// development/proxy endpoints without allowing arbitrary cleartext URLs. +func IsLoopbackHTTP(u string) bool { + parsed, err := neturl.Parse(u) + if err != nil || parsed.Scheme != "http" || parsed.Host == "" { + return false + } + host := strings.ToLower(parsed.Hostname()) + if host == "localhost" { + return true + } + addr, err := netip.ParseAddr(host) + return err == nil && addr.IsLoopback() +} diff --git a/shared/url/url_test.go b/shared/url/url_test.go index 76ad91d5..35645241 100644 --- a/shared/url/url_test.go +++ b/shared/url/url_test.go @@ -115,3 +115,31 @@ func TestTrimTrailingSlashes(t *testing.T) { }) } } + +func TestIsLoopbackHTTP(t *testing.T) { + t.Parallel() + tests := []struct { + input string + want bool + }{ + {"http://localhost:8080/atlassian", true}, + {"http://LOCALHOST/atlassian", true}, + {"http://127.0.0.1:8080/atlassian", true}, + {"http://127.42.0.1:8080/atlassian", true}, + {"http://[::1]:8080/atlassian", true}, + {"https://localhost:8080/atlassian", false}, + {"http://example.com/atlassian", false}, + {"http://10.0.0.1/atlassian", false}, + {"localhost:8080/atlassian", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + if got := IsLoopbackHTTP(tt.input); got != tt.want { + t.Errorf("IsLoopbackHTTP(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} diff --git a/tools/cfl/README.md b/tools/cfl/README.md index a6fcb9c9..20d01ded 100644 --- a/tools/cfl/README.md +++ b/tools/cfl/README.md @@ -202,18 +202,25 @@ cfl init --url https://mycompany.atlassian.net --email you@example.com # Service account with scoped token (Bearer Auth) cfl init --auth-method bearer cfl init --auth-method bearer --url https://mycompany.atlassian.net \ - --token YOUR_SCOPED_TOKEN --cloud-id YOUR_CLOUD_ID --no-verify + --token-from-env CFL_API_TOKEN --cloud-id YOUR_CLOUD_ID --no-verify + +# Trusted proxy (no CLI-side Authorization header) +cfl init --auth-method proxy --url http://127.0.0.1:8080/atlassian --no-verify ``` | Flag | Short | Default | Description | |------|-------|---------|-------------| | `--url` | | | Pre-populate Confluence URL | | `--email` | | | Pre-populate email address | -| `--auth-method` | | | Auth method: `basic` (default) or `bearer` | +| `--token-stdin` | | `false` | Read the API token from stdin | +| `--token-from-env` | | | Read the API token from an environment variable | +| `--auth-method` | | | Auth method: `basic` (default), `bearer`, or `proxy` | | `--cloud-id` | | | Cloud ID for bearer auth (find at `https://your-site.atlassian.net/_edge/tenant_info`) | | `--no-verify` | | `false` | Skip connection verification | > **Bearer Auth:** For [Atlassian service accounts](https://support.atlassian.com/user-management/docs/manage-api-tokens-for-service-accounts/) with scoped API tokens. Email is not required. Requests route through the `api.atlassian.com` gateway. +> +> **Proxy Auth:** Use `--auth-method proxy` for a trusted proxy that injects credentials upstream. The CLI requires only a URL and sends no `Authorization` header. HTTPS URLs are accepted; `http://` URLs are accepted only for loopback hosts such as `localhost`, `127.0.0.1`, or `[::1]`. cfl appends `/wiki` for Confluence API calls. After a successful save, `cfl init` prints the equivalent of `cfl me` so you can confirm which user the saved credentials authenticate as. (Skipped when `--no-verify` is set, since no live API call is made and there is no user to render.) @@ -782,7 +789,7 @@ shared store at `~/.config/atlassian-cli/config.yml`: default: url: https://mycompany.atlassian.net # base URL; cfl appends /wiki on read email: you@example.com - auth_method: basic # or "bearer" + auth_method: basic # or "bearer" / "proxy" cloud_id: "" # required for bearer cfl: default_space: DEV # cfl-only defaults @@ -844,9 +851,12 @@ Environment variables override file-based config. Variables are checked in order | Default Space | `CFL_DEFAULT_SPACE` → shared `cfl.default_space` → legacy | | Auth Method | `CFL_AUTH_METHOD` → `ATLASSIAN_AUTH_METHOD` → shared `default` → legacy → `basic` | | Cloud ID | `CFL_CLOUD_ID` → `ATLASSIAN_CLOUD_ID` → shared `default` → legacy | +| Bearer Gateway Base URL | `CFL_GATEWAY_BASE_URL` → `ATLASSIAN_GATEWAY_BASE_URL` → `https://api.atlassian.com` | Per §2.2 connection config is single-sourced from the shared `default` section — per-tool `cfl:`/`jtk:` sections carry only non-secret defaults and may not override `url`/`email`/`auth_method`/`cloud_id`. +Set `ATLASSIAN_AUTH_METHOD=proxy` (or `CFL_AUTH_METHOD=proxy`) with `ATLASSIAN_URL`/`CFL_URL` to use a trusted proxy. Proxy auth ignores email, token, and cloud ID because the proxy is responsible for upstream authentication. + **Shared credentials:** If you use both `cfl` and `jtk` (Jira CLI), set `ATLASSIAN_*` variables once: ```bash diff --git a/tools/cfl/api/client.go b/tools/cfl/api/client.go index bc523c0d..2aa61652 100644 --- a/tools/cfl/api/client.go +++ b/tools/cfl/api/client.go @@ -11,6 +11,7 @@ import ( "github.com/open-cli-collective/atlassian-go/auth" "github.com/open-cli-collective/atlassian-go/client" + sharedurl "github.com/open-cli-collective/atlassian-go/url" ) // Validation errors for bearer auth. @@ -32,6 +33,14 @@ func NewClient(baseURL, email, apiToken string) *Client { } } +// NewProxyClient creates a Confluence client for a trusted proxy that injects +// authentication upstream. No Authorization header is sent by the CLI. +func NewProxyClient(baseURL string) *Client { + return &Client{ + Client: client.New(normalizeWikiBaseURL(baseURL), "", "", &client.Options{SkipAuthHeader: true}), + } +} + // NewBearerClient creates a new Confluence API client using bearer auth via the API gateway. // The cloudID is used to construct the gateway URL: https://api.atlassian.com/ex/confluence/{cloudId}/wiki func NewBearerClient(apiToken, cloudID string) (*Client, error) { @@ -41,7 +50,7 @@ func NewBearerClient(apiToken, cloudID string) (*Client, error) { if cloudID == "" { return nil, ErrCloudIDRequired } - gatewayBase := fmt.Sprintf("%s/ex/confluence/%s/wiki", client.GatewayBaseURL, cloudID) + gatewayBase := fmt.Sprintf("%s/ex/confluence/%s/wiki", client.GatewayBaseURLFromEnv("CFL_GATEWAY_BASE_URL"), cloudID) opts := &client.Options{ AuthHeader: auth.BearerAuthHeader(apiToken), } @@ -50,6 +59,14 @@ func NewBearerClient(apiToken, cloudID string) (*Client, error) { }, nil } +func normalizeWikiBaseURL(baseURL string) string { + baseURL = sharedurl.NormalizeURL(baseURL) + if !strings.HasSuffix(baseURL, "/wiki") { + baseURL += "/wiki" + } + return baseURL +} + // GetHTTPClient returns the underlying HTTP client for custom requests. func (c *Client) GetHTTPClient() *http.Client { return c.HTTPClient diff --git a/tools/cfl/api/client_test.go b/tools/cfl/api/client_test.go index 9e9fd9bb..38874d1a 100644 --- a/tools/cfl/api/client_test.go +++ b/tools/cfl/api/client_test.go @@ -23,6 +23,27 @@ func TestNewClient(t *testing.T) { testutil.Contains(t, client.GetAuthHeader(), "Basic ") } +func TestNewProxyClient(t *testing.T) { + t.Parallel() + var capturedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + testutil.Equal(t, "/wiki/api/v2/spaces", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + client := NewProxyClient(server.URL) + testutil.NotNil(t, client) + testutil.Equal(t, server.URL+"/wiki", client.GetBaseURL()) + testutil.Equal(t, "", client.GetAuthHeader()) + + _, err := client.Get(context.Background(), "/api/v2/spaces") + testutil.RequireNoError(t, err) + testutil.Equal(t, "", capturedAuth) +} + func TestClient_AuthHeader(t *testing.T) { t.Parallel() var capturedAuth string @@ -243,3 +264,11 @@ func TestNewBearerClient(t *testing.T) { testutil.Equal(t, "https://example.atlassian.net/wiki", c.GetBaseURL()) }) } + +func TestNewBearerClient_GatewayBaseFromEnv(t *testing.T) { + t.Setenv("CFL_GATEWAY_BASE_URL", "https://gateway.example/") + t.Setenv("ATLASSIAN_GATEWAY_BASE_URL", "") + c, err := NewBearerClient("scoped-token", "abc-123") + testutil.RequireNoError(t, err) + testutil.Equal(t, "https://gateway.example/ex/confluence/abc-123/wiki", c.GetBaseURL()) +} diff --git a/tools/cfl/docs/development.md b/tools/cfl/docs/development.md index dcb37a58..48dd8739 100644 --- a/tools/cfl/docs/development.md +++ b/tools/cfl/docs/development.md @@ -74,7 +74,7 @@ Add new Confluence macros through `MacroRegistry` in `macro.go`; the tokenizer, `cfl` participates in the shared Atlassian credential/config model described by the monorepo guide. `ATLASSIAN_*` variables apply across both tools; `CFL_*` variables override for cfl. The cfl-specific config section carries non-secret defaults such as `default_space` and `output_format`. -Basic auth uses an instance URL plus email and token. Bearer auth routes through `api.atlassian.com` and requires a cloud ID. `cfl init` and `cfl me` verify against Confluence's current-user endpoint. +Basic auth uses an instance URL plus email and token. Bearer auth routes through `api.atlassian.com` and requires a cloud ID. Proxy auth uses only a URL, sends no `Authorization` header, and allows loopback `http://` URLs for trusted local proxies while keeping arbitrary cleartext proxy URLs rejected. Bearer gateway routing can be overridden with `CFL_GATEWAY_BASE_URL` or `ATLASSIAN_GATEWAY_BASE_URL`. `cfl init` and `cfl me` verify against Confluence's current-user endpoint. ## Output diff --git a/tools/cfl/integration-tests.md b/tools/cfl/integration-tests.md index f4ea3b63..1a264b56 100644 --- a/tools/cfl/integration-tests.md +++ b/tools/cfl/integration-tests.md @@ -6,12 +6,13 @@ This document catalogs the manual integration test suite for `cfl`. These tests ## Auth Methods -cfl supports two authentication methods. The full integration test suite should be run with both: +cfl supports three authentication methods. The full integration test suite should be run with each configured auth method: - **Basic Auth** (default): Classic API tokens using `email:token` against the instance URL. - **Bearer Auth**: Scoped API tokens for service accounts using `Authorization: Bearer ` against the `api.atlassian.com` gateway. +- **Proxy Auth**: Trusted proxy setup using `--auth-method proxy`; the CLI sends no `Authorization` header and requires only a URL. -All cfl commands should work with both auth methods (no scope limitations for Confluence). +All cfl commands should work with all configured auth methods (no scope limitations for Confluence). --- @@ -27,6 +28,10 @@ All cfl commands should work with both auth methods (no scope limitations for Co - Your Cloud ID (find at `https://your-site.atlassian.net/_edge/tenant_info`) - `cfl init --auth-method bearer` completed +### Proxy Auth Prerequisites +- A trusted proxy that authenticates upstream and exposes Confluence `/wiki` paths. +- `cfl init --auth-method proxy --url ` completed. + ### Test Data Conventions - Test pages use `[Test]` prefix: `[Test] My Page` - Baseline pages (for comparison) use `[Baseline]` prefix @@ -587,7 +592,7 @@ Copies in the TEST space (originals from INT, CUS, PROD, PLAYBOOK): ## Test Execution Checklist -All cfl commands work with both auth methods (no scope restrictions for Confluence). Run the full checklist twice with separate passes to ensure both auth paths work. +All cfl commands work with all configured auth methods (no scope restrictions for Confluence). Run the full checklist with separate passes to ensure each auth path works. ### Pass 1: Basic Auth diff --git a/tools/cfl/internal/cmd/init/init.go b/tools/cfl/internal/cmd/init/init.go index 97456c9c..5f6f6875 100644 --- a/tools/cfl/internal/cmd/init/init.go +++ b/tools/cfl/internal/cmd/init/init.go @@ -23,13 +23,16 @@ import ( // clientBuilder constructs an *api.Client from a config. // Pulled out as a parameter so tests can inject an httptest-pointed client -// without depending on api.NewBearerClient's hardcoded gateway URL. +// without depending on api.NewBearerClient's gateway URL. type clientBuilder func(cfg *config.Config) (*api.Client, error) func defaultClientBuilder(cfg *config.Config) (*api.Client, error) { if cfg.AuthMethod == auth.AuthMethodBearer { return api.NewBearerClient(cfg.APIToken, cfg.CloudID) } + if cfg.AuthMethod == auth.AuthMethodProxy { + return api.NewProxyClient(cfg.URL), nil + } return api.NewClient(cfg.URL, cfg.Email, cfg.APIToken), nil } @@ -67,6 +70,10 @@ For service account scoped tokens (bearer auth): Use --auth-method bearer with your scoped API token and Cloud ID. Find your Cloud ID at: https://your-site.atlassian.net/_edge/tenant_info +For trusted local proxies: + Use --auth-method proxy with the proxy URL. The CLI sends no + Authorization header; the proxy is expected to authenticate upstream. + Scripted ingress (§1.5.1): use --token-stdin or --token-from-env VAR for the API token. cfl init has never had a --token flag because flag-passed plaintext secrets leak into shell history and process @@ -84,7 +91,10 @@ listings.`, # Service account (bearer auth) setup cfl init --auth-method bearer --url https://mycompany.atlassian.net \ - --token-from-env CFL_API_TOKEN --cloud-id YOUR_CLOUD_ID`, + --token-from-env CFL_API_TOKEN --cloud-id YOUR_CLOUD_ID + + # Local proxy setup + cfl init --auth-method proxy --url http://127.0.0.1:8080/atlassian --no-verify`, RunE: func(cmd *cobra.Command, _ []string) error { return runInit(cmd.Context(), opts, url, email, tokenStdin, tokenFromEnv, authMethod, cloudID, noVerify) }, @@ -94,7 +104,7 @@ listings.`, cmd.Flags().StringVar(&email, "email", "", "Your Atlassian account email") cmd.Flags().BoolVar(&tokenStdin, "token-stdin", false, "Read the API token from stdin (xor with --token-from-env)") cmd.Flags().StringVar(&tokenFromEnv, "token-from-env", "", "Read the API token from this env var (xor with --token-stdin)") - cmd.Flags().StringVar(&authMethod, "auth-method", "", "Authentication method: basic (default) or bearer") + cmd.Flags().StringVar(&authMethod, "auth-method", "", "Authentication method: basic (default), bearer, or proxy") cmd.Flags().StringVar(&cloudID, "cloud-id", "", "Atlassian Cloud ID (required for bearer auth)") cmd.Flags().BoolVar(&noVerify, "no-verify", false, "Skip connection verification") @@ -132,14 +142,17 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail s return err } cfg := result.prefill - - // Now the one-time §1.8 token migration: relocate any pre-existing - // legacy plaintext token into the single shared keyring api_token - // (token-only, connection-preserving scrub) before the user sets a - // new one. - if err := keyring.EnsureMigrated(); err != nil { - v.Error("Could not prepare secure credential storage: %v", err) - return err + isProxy := cfg.AuthMethod == auth.AuthMethodProxy + + if !isProxy { + // Now the one-time §1.8 token migration: relocate any pre-existing + // legacy plaintext token into the single shared keyring api_token + // (token-only, connection-preserving scrub) before the user sets a + // new one. + if err := keyring.EnsureMigrated(); err != nil { + v.Error("Could not prepare secure credential storage: %v", err) + return err + } } // EnsureMigrated relocated any legacy plaintext token into the @@ -178,7 +191,7 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail s cfg.APIToken = scripted } - if cfg.APIToken == "" { + if !isProxy && cfg.APIToken == "" { if tok, _, terr := keyring.ResolveTokenNoMigrate(credstore.ToolCFL); terr == nil { cfg.APIToken = tok } @@ -190,7 +203,28 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail s // Build the form based on auth method var formGroups []*huh.Group - if isBearer { + if isProxy { + // Proxy auth: URL only. The proxy injects authentication upstream. + formGroups = append(formGroups, huh.NewGroup( + huh.NewInput(). + Title("Confluence Proxy URL"). + Description("Trusted proxy URL; loopback http or https"). + Placeholder("http://127.0.0.1:8080/atlassian"). + Value(&cfg.URL). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("URL is required") + } + return nil + }), + + huh.NewInput(). + Title("Default Space (optional)"). + Description("Default space key for page operations"). + Placeholder("MYSPACE"). + Value(&cfg.DefaultSpace), + )) + } else if isBearer { // Bearer auth: URL + token + cloud ID (no email) formGroups = append(formGroups, huh.NewGroup( huh.NewInput(). @@ -288,7 +322,7 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail s // (via `cfl set-credential`). Fail loud naming the first missing // field. if !prompt.WantPrompt(opts.NonInteractive, opts.Stdin) { - if err := requireNonInteractiveFields(cfg, isBearer); err != nil { + if err := requireNonInteractiveFields(cfg); err != nil { return err } } else { @@ -299,6 +333,7 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail s } cfg.NormalizeURL() + normalizeAuthConfig(cfg) if err := cfg.Validate(); err != nil { return fmt.Errorf("invalid configuration: %w", err) @@ -372,16 +407,19 @@ func finalizeInit( return fmt.Errorf("saving shared store: %w", err) } - // The token never lands in the plaintext store (Save strips it) — it - // goes to the OS keyring under the single shared api_token (§1.11.10: - // one key for both jtk and cfl; the reconcile write-target governs - // only NON-secret placement, untouched here). - if err := keyring.PersistToken(cfg.APIToken); err != nil { - v.Error("Saved the non-secret config to %s, but could not store the API token in the keyring: %v", sharedPath, err) - v.Error("Recover by storing just the token (no need to re-run init): `cfl set-credential --ref atlassian-cli/default --key api_token --stdin --overwrite` (reads stdin; use --from-env VAR for env-driven setup).") - return err + // Direct auth tokens never land in the plaintext store (Save strips them). + // Proxy auth has no CLI-side token; basic/bearer tokens go to the OS + // keyring under the single shared api_token (§1.11.10). + if cfg.AuthMethod == auth.AuthMethodProxy { + v.Success("Configuration saved to %s (proxy auth; no token stored)", sharedPath) + } else { + if err := keyring.PersistToken(cfg.APIToken); err != nil { + v.Error("Saved the non-secret config to %s, but could not store the API token in the keyring: %v", sharedPath, err) + v.Error("Recover by storing just the token (no need to re-run init): `cfl set-credential --ref atlassian-cli/default --key api_token --stdin --overwrite` (reads stdin; use --from-env VAR for env-driven setup).") + return err + } + v.Success("Configuration saved to %s (token stored in the OS keyring)", sharedPath) } - v.Success("Configuration saved to %s (token stored in the OS keyring)", sharedPath) // Optional: clean up legacy files we just migrated. for _, lp := range result.consumedLegacies { @@ -426,24 +464,41 @@ func finalizeInit( if cfg.AuthMethod == auth.AuthMethodBearer { v.Println("") v.Info("To switch back to basic auth later, run: cfl init --auth-method basic") + } else if cfg.AuthMethod == auth.AuthMethodProxy { + v.Println("") + v.Info("To switch to direct authentication later, run: cfl init --auth-method basic") } return nil } +func normalizeAuthConfig(cfg *config.Config) { + if cfg.AuthMethod == "" { + cfg.AuthMethod = auth.AuthMethodBasic + } + if cfg.AuthMethod == auth.AuthMethodProxy { + cfg.Email = "" + cfg.APIToken = "" + cfg.CloudID = "" + } +} + // requireNonInteractiveFields enforces the §3.4 fail-loud contract for // scripted/CI runs of `cfl init`. cfl init has no --token flag, so the // token MUST come from a pre-staged keyring entry via cfl set-credential; // the error names that path explicitly. -func requireNonInteractiveFields(cfg *config.Config, isBearer bool) error { +func requireNonInteractiveFields(cfg *config.Config) error { if cfg.URL == "" { return fmt.Errorf("--non-interactive: missing required value for --url") } - if isBearer { + switch cfg.AuthMethod { + case auth.AuthMethodProxy: + return nil + case auth.AuthMethodBearer: if cfg.CloudID == "" { return fmt.Errorf("--non-interactive: missing required value for --cloud-id (bearer auth)") } - } else { + default: if cfg.Email == "" { return fmt.Errorf("--non-interactive: missing required value for --email (basic auth)") } diff --git a/tools/cfl/internal/cmd/init/init_test.go b/tools/cfl/internal/cmd/init/init_test.go index 67c31812..c7679eeb 100644 --- a/tools/cfl/internal/cmd/init/init_test.go +++ b/tools/cfl/internal/cmd/init/init_test.go @@ -131,30 +131,28 @@ func TestRunInit_InvalidAuthMethod(t *testing.T) { func TestRequireNonInteractiveFields_NamesFirstMissing(t *testing.T) { t.Parallel() tests := []struct { - name string - cfg *config.Config - isBearer bool - wants []string + name string + cfg *config.Config + wants []string }{ - {"basic — missing URL", &config.Config{}, false, []string{"--url"}}, - {"basic — missing email", &config.Config{URL: "https://acme.atlassian.net"}, false, []string{"--email"}}, - {"bearer — missing cloud-id", &config.Config{URL: "https://acme.atlassian.net"}, true, []string{"--cloud-id"}}, + {"basic — missing URL", &config.Config{}, []string{"--url"}}, + {"basic — missing email", &config.Config{URL: "https://acme.atlassian.net"}, []string{"--email"}}, + {"bearer — missing cloud-id", &config.Config{URL: "https://acme.atlassian.net", AuthMethod: auth.AuthMethodBearer}, []string{"--cloud-id"}}, { name: "basic — missing token recommends --token-stdin + --token-from-env + set-credential", cfg: &config.Config{URL: "https://acme.atlassian.net", Email: "u@x.io"}, wants: []string{"--token-stdin", "--token-from-env", "set-credential"}, }, { - name: "bearer — missing token recommends --token-stdin + --token-from-env + set-credential", - cfg: &config.Config{URL: "https://acme.atlassian.net", CloudID: "cid"}, - isBearer: true, - wants: []string{"--token-stdin", "--token-from-env", "set-credential"}, + name: "bearer — missing token recommends --token-stdin + --token-from-env + set-credential", + cfg: &config.Config{URL: "https://acme.atlassian.net", CloudID: "cid", AuthMethod: auth.AuthMethodBearer}, + wants: []string{"--token-stdin", "--token-from-env", "set-credential"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := requireNonInteractiveFields(tc.cfg, tc.isBearer) + err := requireNonInteractiveFields(tc.cfg) testutil.RequireError(t, err) if !strings.Contains(err.Error(), "--non-interactive") { t.Fatalf("error must mention --non-interactive: %v", err) @@ -174,7 +172,18 @@ func TestRequireNonInteractiveFields_AllSupplied_NoError(t *testing.T) { URL: "https://acme.atlassian.net", Email: "u@x.io", APIToken: "tok-1234567890", } - if err := requireNonInteractiveFields(cfg, false); err != nil { + if err := requireNonInteractiveFields(cfg); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRequireNonInteractiveFields_ProxyNeedsOnlyURL(t *testing.T) { + t.Parallel() + cfg := &config.Config{ + URL: "http://127.0.0.1:8080/atlassian", + AuthMethod: auth.AuthMethodProxy, + } + if err := requireNonInteractiveFields(cfg); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -221,6 +230,35 @@ func TestRunInit_NonInteractive_MissingToken_RecommendsAllPaths(t *testing.T) { } } +func TestRunInit_Proxy_NoTokenRequiredOrPersisted(t *testing.T) { + credtest.Hermetic(t) + opts := &root.Options{ + Output: "table", + NoColor: true, + NonInteractive: true, + Stdin: strings.NewReader(""), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + } + err := runInit(context.Background(), opts, + "http://127.0.0.1:8080/atlassian", "", false, "", auth.AuthMethodProxy, "", true) + testutil.RequireNoError(t, err) + + store, err := credstore.Load(credtest.SharedConfigPath(t)) + testutil.RequireNoError(t, err) + testutil.Equal(t, "http://127.0.0.1:8080/atlassian", store.Default.URL) + testutil.Equal(t, "", store.Default.Email) + testutil.Equal(t, auth.AuthMethodProxy, store.Default.AuthMethod) + testutil.Equal(t, "", store.Default.CloudID) + + s, err := keyring.OpenNoMigrate() + testutil.RequireNoError(t, err) + defer func() { _ = s.Close() }() + ok, err := s.HasToken(keyring.KeyAPIToken) + testutil.RequireNoError(t, err) + testutil.False(t, ok) +} + // finalizeInit tests use t.TempDir() for paths and an httptest-backed // clientBuilder so the user's real config is never touched and no real // network call is made. @@ -412,6 +450,48 @@ func TestDefaultClientBuilder(t *testing.T) { _, err := defaultClientBuilder(cfg) testutil.RequireError(t, err) }) + + t.Run("proxy constructs no-auth client", func(t *testing.T) { + t.Parallel() + cfg := &config.Config{ + URL: "http://127.0.0.1:8080/atlassian", + AuthMethod: auth.AuthMethodProxy, + } + c, err := defaultClientBuilder(cfg) + testutil.RequireNoError(t, err) + testutil.Equal(t, "http://127.0.0.1:8080/atlassian/wiki", c.BaseURL) + testutil.Equal(t, "", c.AuthHeader) + }) +} + +func TestFinalizeInit_ProxyNoTokenPersisted(t *testing.T) { + credtest.Hermetic(t) + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yml") + opts := newFinalizeOpts() + cfg := &config.Config{ + URL: "http://127.0.0.1:8080/atlassian/wiki", + AuthMethod: auth.AuthMethodProxy, + DefaultSpace: "DEV", + } + + err := finalizeInit(context.Background(), opts, cfg, newFinalizeReconcileResult(), configPath, true, defaultClientBuilder) + testutil.RequireNoError(t, err) + + loaded, err := credstore.Load(configPath) + testutil.RequireNoError(t, err) + testutil.Equal(t, "http://127.0.0.1:8080/atlassian", loaded.Default.URL) + testutil.Equal(t, "", loaded.Default.Email) + testutil.Equal(t, auth.AuthMethodProxy, loaded.Default.AuthMethod) + testutil.Equal(t, "", loaded.Default.CloudID) + testutil.Equal(t, "DEV", loaded.CFL.DefaultSpace) + + s, err := keyring.OpenNoMigrate() + testutil.RequireNoError(t, err) + defer func() { _ = s.Close() }() + ok, err := s.HasToken(keyring.KeyAPIToken) + testutil.RequireNoError(t, err) + testutil.False(t, ok) } func TestFinalizeInit_AuthFailure(t *testing.T) { diff --git a/tools/cfl/internal/cmd/root/root.go b/tools/cfl/internal/cmd/root/root.go index 8d410235..a2a0639e 100644 --- a/tools/cfl/internal/cmd/root/root.go +++ b/tools/cfl/internal/cmd/root/root.go @@ -90,6 +90,9 @@ func (o *Options) APIClient() (*api.Client, error) { if cfg.AuthMethod == auth.AuthMethodBearer { return api.NewBearerClient(cfg.APIToken, cfg.CloudID) } + if cfg.AuthMethod == auth.AuthMethodProxy { + return api.NewProxyClient(cfg.URL), nil + } return api.NewClient(cfg.URL, cfg.Email, cfg.APIToken), nil } diff --git a/tools/cfl/internal/cmd/root/root_test.go b/tools/cfl/internal/cmd/root/root_test.go index 7d7b506f..3537cc6a 100644 --- a/tools/cfl/internal/cmd/root/root_test.go +++ b/tools/cfl/internal/cmd/root/root_test.go @@ -2,12 +2,17 @@ package root import ( "bytes" + "net/http" + "net/http/httptest" "testing" "github.com/open-cli-collective/atlassian-go/artifact" + "github.com/open-cli-collective/atlassian-go/auth" "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/atlassian-go/view" "github.com/spf13/cobra" + + "github.com/open-cli-collective/confluence-cli/internal/config" ) func TestNewCmd(t *testing.T) { @@ -114,6 +119,29 @@ func TestRegisterCommands(t *testing.T) { testutil.True(t, called) } +func TestOptions_APIClient_ProxySkipsAuthHeader(t *testing.T) { + var capturedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + _, opts := NewCmd() + opts.SetConfig(&config.Config{ + URL: server.URL, + AuthMethod: auth.AuthMethodProxy, + }) + c, err := opts.APIClient() + testutil.RequireNoError(t, err) + testutil.Equal(t, "", c.GetAuthHeader()) + + _, err = c.Get(t.Context(), "/api/v2/spaces") + testutil.RequireNoError(t, err) + testutil.Equal(t, "", capturedAuth) +} + func TestValidateOutputFormat(t *testing.T) { t.Parallel() diff --git a/tools/cfl/internal/config/config.go b/tools/cfl/internal/config/config.go index 0788f3d2..b0b5ed9c 100644 --- a/tools/cfl/internal/config/config.go +++ b/tools/cfl/internal/config/config.go @@ -13,6 +13,7 @@ import ( sharedconfig "github.com/open-cli-collective/atlassian-go/config" "github.com/open-cli-collective/atlassian-go/credstore" "github.com/open-cli-collective/atlassian-go/keyring" + sharedurl "github.com/open-cli-collective/atlassian-go/url" "gopkg.in/yaml.v3" ) @@ -23,7 +24,7 @@ type Config struct { APIToken string `yaml:"api_token"` DefaultSpace string `yaml:"default_space,omitempty"` OutputFormat string `yaml:"output_format,omitempty"` - AuthMethod string `yaml:"auth_method,omitempty"` // "basic" (default) or "bearer" + AuthMethod string `yaml:"auth_method,omitempty"` // "basic" (default), "bearer", or "proxy" CloudID string `yaml:"cloud_id,omitempty"` // Required for bearer auth (gateway URL) Keyring KeyringConfig `yaml:"keyring,omitempty"` } @@ -41,13 +42,11 @@ type KeyringConfig struct { // Validate checks that all required fields are present and valid. // For bearer auth: URL + API token + Cloud ID are required (no email). // For basic auth: URL + email + API token are required. +// For proxy auth: only URL is required; no Authorization header is sent. func (c *Config) Validate() error { if c.URL == "" { return errors.New("url is required") } - if c.APIToken == "" { - return errors.New("api_token is required") - } // Validate auth method if set (empty defaults to basic) if c.AuthMethod != "" { @@ -56,21 +55,33 @@ func (c *Config) Validate() error { } } - if c.AuthMethod == auth.AuthMethodBearer { + // Validate URL scheme. Proxy auth may use loopback http for a local proxy; + // all other cleartext URLs are rejected. + if !strings.HasPrefix(c.URL, "https://") { + if c.AuthMethod != auth.AuthMethodProxy || !sharedurl.IsLoopbackHTTP(c.URL) { + return errors.New("url must use https unless proxy auth uses loopback http") + } + } + + switch c.AuthMethod { + case auth.AuthMethodProxy: + return nil + case auth.AuthMethodBearer: + if c.APIToken == "" { + return errors.New("api_token is required") + } if c.CloudID == "" { return errors.New("cloud_id is required for bearer auth") } - } else { + default: + if c.APIToken == "" { + return errors.New("api_token is required") + } if c.Email == "" { return errors.New("email is required") } } - // Validate URL scheme - if !strings.HasPrefix(c.URL, "https://") { - return errors.New("url must use https") - } - return nil } @@ -268,6 +279,11 @@ func LoadWithEnv(path string) (*Config, error) { cfg.LoadFromEnv() + if cfg.AuthMethod == auth.AuthMethodProxy { + cfg.APIToken = "" + return cfg, nil + } + // Authoritative token resolution: overwrites any token a legacy-file // parse may have populated, so plaintext can never reach the client. tok, _, kErr := keyring.ResolveToken(credstore.ToolCFL) diff --git a/tools/cfl/internal/config/config_test.go b/tools/cfl/internal/config/config_test.go index b680926c..f07a40ed 100644 --- a/tools/cfl/internal/config/config_test.go +++ b/tools/cfl/internal/config/config_test.go @@ -66,6 +66,31 @@ func TestConfig_Validate(t *testing.T) { wantErr: true, errMsg: "url must use https", }, + { + name: "proxy with loopback http URL needs no token or email", + config: Config{ + URL: "http://127.0.0.1:8080/atlassian/wiki", + AuthMethod: "proxy", + }, + wantErr: false, + }, + { + name: "proxy with https URL needs no token or email", + config: Config{ + URL: "https://proxy.example.com/atlassian/wiki", + AuthMethod: "proxy", + }, + wantErr: false, + }, + { + name: "proxy rejects arbitrary http URL", + config: Config{ + URL: "http://example.com/atlassian/wiki", + AuthMethod: "proxy", + }, + wantErr: true, + errMsg: "url must use https", + }, { name: "valid bearer config", config: Config{ @@ -603,3 +628,16 @@ func TestLoadWithEnv_CorruptSharedFallsBackToLegacy(t *testing.T) { // but the command still works (no error). testutil.Equal(t, "", cfg.APIToken) } + +func TestLoadWithEnv_ProxySkipsTokenResolution(t *testing.T) { + credtest.Hermetic(t) + t.Setenv("ATLASSIAN_URL", "http://127.0.0.1:8080/atlassian") + t.Setenv("ATLASSIAN_AUTH_METHOD", "proxy") + t.Setenv("ATLASSIAN_API_TOKEN", "env-token-that-should-be-ignored") + + cfg, err := LoadWithEnv(filepath.Join(t.TempDir(), "missing.yml")) + testutil.RequireNoError(t, err) + testutil.Equal(t, "http://127.0.0.1:8080/atlassian", cfg.URL) + testutil.Equal(t, "proxy", cfg.AuthMethod) + testutil.Equal(t, "", cfg.APIToken) +} diff --git a/tools/jtk/README.md b/tools/jtk/README.md index 9313b51c..5e7ea235 100644 --- a/tools/jtk/README.md +++ b/tools/jtk/README.md @@ -176,21 +176,28 @@ jtk init --url https://mycompany.atlassian.net --email user@example.com # Service account with scoped token (Bearer Auth) jtk init --auth-method bearer jtk init --auth-method bearer --url https://mycompany.atlassian.net \ - --token YOUR_SCOPED_TOKEN --cloud-id YOUR_CLOUD_ID --no-verify + --token-from-env JIRA_API_TOKEN --cloud-id YOUR_CLOUD_ID --no-verify + +# Trusted proxy (no CLI-side Authorization header) +jtk init --auth-method proxy --url http://127.0.0.1:8080/atlassian --no-verify ``` | Flag | Default | Description | |------|---------|-------------| | `--url` | | Jira URL (e.g., `https://mycompany.atlassian.net`) | | `--email` | | Email address for authentication | -| `--token` | | API token | -| `--auth-method` | | Auth method: `basic` (default) or `bearer` | +| `--token` | | Deprecated API token literal; prefer `--token-stdin` or `--token-from-env` | +| `--token-stdin` | `false` | Read the API token from stdin | +| `--token-from-env` | | Read the API token from an environment variable | +| `--auth-method` | | Auth method: `basic` (default), `bearer`, or `proxy` | | `--cloud-id` | | Cloud ID for bearer auth (find at `https://your-site.atlassian.net/_edge/tenant_info`) | | `--no-verify` | `false` | Skip connection verification | > **Bearer Auth:** For [Atlassian service accounts](https://support.atlassian.com/user-management/docs/manage-api-tokens-for-service-accounts/) with scoped API tokens. Email is not required. Requests route through the `api.atlassian.com` gateway. > > **Scope limitations:** Scoped tokens don't have scopes for Agile (boards/sprints), Automation, or Dashboards. These commands are unavailable with bearer auth — this is an Atlassian platform limitation. +> +> **Proxy Auth:** Use `--auth-method proxy` for a trusted proxy that injects credentials upstream. The CLI requires only a URL and sends no `Authorization` header. HTTPS URLs are accepted; `http://` URLs are accepted only for loopback hosts such as `localhost`, `127.0.0.1`, or `[::1]`. --- @@ -1614,7 +1621,7 @@ shared store at `~/.config/atlassian-cli/config.yml`: default: url: https://mycompany.atlassian.net email: user@example.com - auth_method: basic # or "bearer" + auth_method: basic # or "bearer" / "proxy" cloud_id: "" # required for bearer jtk: default_project: MYPROJECT # jtk-only defaults @@ -1673,9 +1680,12 @@ Environment variables override file-based config. Variables are checked in order | Default Project | `JIRA_DEFAULT_PROJECT` → shared `jtk.default_project` → legacy | | Auth Method | `JIRA_AUTH_METHOD` → `ATLASSIAN_AUTH_METHOD` → shared `default` → legacy → `basic` | | Cloud ID | `JIRA_CLOUD_ID` → `ATLASSIAN_CLOUD_ID` → shared `default` → legacy | +| Bearer Gateway Base URL | `JIRA_GATEWAY_BASE_URL` → `ATLASSIAN_GATEWAY_BASE_URL` → `https://api.atlassian.com` | Per §2.2 connection config is single-sourced from the shared `default` section — per-tool `cfl:`/`jtk:` sections carry only non-secret defaults and may not override `url`/`email`/`auth_method`/`cloud_id`. +Set `ATLASSIAN_AUTH_METHOD=proxy` (or `JIRA_AUTH_METHOD=proxy`) with `ATLASSIAN_URL`/`JIRA_URL` to use a trusted proxy. Proxy auth ignores email, token, and cloud ID because the proxy is responsible for upstream authentication. + **Shared credentials:** If you use both `jtk` and `cfl` (Confluence CLI), set `ATLASSIAN_*` variables once: ```bash diff --git a/tools/jtk/api/client.go b/tools/jtk/api/client.go index c9cae9d8..6f2e0047 100644 --- a/tools/jtk/api/client.go +++ b/tools/jtk/api/client.go @@ -39,20 +39,18 @@ type ClientConfig struct { Email string APIToken string Verbose bool - AuthMethod string // "basic" (default) or "bearer" + AuthMethod string // "basic" (default), "bearer", or "proxy" CloudID string // Required for bearer auth (used to construct gateway URL) } // New creates a new Jira API client from config. // For bearer auth: URL + API token + Cloud ID are required (no email). // For basic auth: URL + email + API token are required. +// For proxy auth: only URL is required; no Authorization header is sent. func New(cfg ClientConfig) (*Client, error) { if cfg.URL == "" { return nil, ErrURLRequired } - if cfg.APIToken == "" { - return nil, ErrAPITokenRequired - } if cfg.AuthMethod != "" { if err := auth.ValidateAuthMethod(cfg.AuthMethod); err != nil { @@ -60,14 +58,20 @@ func New(cfg ClientConfig) (*Client, error) { } } - if cfg.AuthMethod == auth.AuthMethodBearer { + switch cfg.AuthMethod { + case auth.AuthMethodBearer: return newBearerClient(cfg) + case auth.AuthMethodProxy: + return newProxyClient(cfg) } // Basic auth (default) if cfg.Email == "" { return nil, ErrEmailRequired } + if cfg.APIToken == "" { + return nil, ErrAPITokenRequired + } // Normalize URL: ensure https and no trailing slash baseURL := url.NormalizeURL(cfg.URL) @@ -89,6 +93,9 @@ func New(cfg ClientConfig) (*Client, error) { // newBearerClient creates a client configured for bearer auth via the API gateway. func newBearerClient(cfg ClientConfig) (*Client, error) { + if cfg.APIToken == "" { + return nil, ErrAPITokenRequired + } if cfg.CloudID == "" { return nil, ErrCloudIDRequired } @@ -97,7 +104,7 @@ func newBearerClient(cfg ClientConfig) (*Client, error) { instanceURL := url.NormalizeURL(cfg.URL) // Gateway URLs for bearer auth - gatewayBase := fmt.Sprintf("%s/ex/jira/%s", client.GatewayBaseURL, cfg.CloudID) + gatewayBase := fmt.Sprintf("%s/ex/jira/%s", client.GatewayBaseURLFromEnv("JIRA_GATEWAY_BASE_URL"), cfg.CloudID) restURL := gatewayBase + "/rest/api/3" opts := &client.Options{ @@ -117,6 +124,28 @@ func newBearerClient(cfg ClientConfig) (*Client, error) { }, nil } +// newProxyClient creates a client configured for a trusted proxy that injects +// authentication upstream. No Authorization header is sent by the CLI. +func newProxyClient(cfg ClientConfig) (*Client, error) { + baseURL := url.NormalizeURL(cfg.URL) + if strings.HasPrefix(baseURL, "http://") && !url.IsLoopbackHTTP(baseURL) { + return nil, ErrProxyURLRequiresHTTPS + } + restURL := baseURL + "/rest/api/3" + + opts := &client.Options{SkipAuthHeader: true} + if cfg.Verbose { + opts.Verbose = true + } + + return &Client{ + Client: client.New(restURL, "", "", opts), + URL: baseURL, + BaseURL: restURL, + AgileURL: baseURL + "/rest/agile/1.0", + }, nil +} + // SupportsAgile returns true if the client can access the Agile REST API. // Bearer auth clients (service accounts with scoped tokens) cannot access // the Agile API because Atlassian does not provide an Agile scope. @@ -133,10 +162,11 @@ func (c *Client) IsBearerAuth() bool { // Validation errors var ( - ErrURLRequired = stderrors.New("URL is required") - ErrEmailRequired = stderrors.New("email is required") - ErrAPITokenRequired = stderrors.New("API token is required") - ErrCloudIDRequired = stderrors.New("cloud ID is required for bearer auth") + ErrURLRequired = stderrors.New("URL is required") + ErrEmailRequired = stderrors.New("email is required") + ErrAPITokenRequired = stderrors.New("API token is required") + ErrCloudIDRequired = stderrors.New("cloud ID is required for bearer auth") + ErrProxyURLRequiresHTTPS = stderrors.New("proxy auth URL must use https unless it is loopback http") ) // ErrAgileUnavailable is returned when a command requires the Agile API diff --git a/tools/jtk/api/client_test.go b/tools/jtk/api/client_test.go index 478747fb..1985cc73 100644 --- a/tools/jtk/api/client_test.go +++ b/tools/jtk/api/client_test.go @@ -424,6 +424,45 @@ func TestNew_BearerAuth(t *testing.T) { testutil.RequireNoError(t, err) }) + t.Run("proxy auth requires URL only and sends no auth header", func(t *testing.T) { + t.Parallel() + var capturedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + testutil.Equal(t, "/rest/api/3/myself", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + c, err := New(ClientConfig{ + URL: server.URL, + AuthMethod: "proxy", + }) + testutil.RequireNoError(t, err) + testutil.Equal(t, server.URL+"/rest/api/3", c.BaseURL) + testutil.Equal(t, "", c.GetAuthHeader()) + testutil.True(t, c.SupportsAgile()) + testutil.False(t, c.IsBearerAuth()) + + _, err = c.Get(context.Background(), "/myself") + testutil.RequireNoError(t, err) + testutil.Equal(t, "", capturedAuth) + }) + + t.Run("proxy auth rejects arbitrary http URLs", func(t *testing.T) { + t.Parallel() + c, err := New(ClientConfig{ + URL: "http://example.com", + AuthMethod: "proxy", + }) + testutil.Error(t, err) + testutil.Nil(t, c) + if !errors.Is(err, ErrProxyURLRequiresHTTPS) { + t.Errorf("got error %v, want %v", err, ErrProxyURLRequiresHTTPS) + } + }) + t.Run("basic auth unchanged when AuthMethod empty", func(t *testing.T) { t.Parallel() c, err := New(ClientConfig{ @@ -473,3 +512,16 @@ func TestNew_BearerAuth(t *testing.T) { } }) } + +func TestNew_BearerAuth_GatewayBaseFromEnv(t *testing.T) { + t.Setenv("JIRA_GATEWAY_BASE_URL", "https://gateway.example/") + t.Setenv("ATLASSIAN_GATEWAY_BASE_URL", "") + c, err := New(ClientConfig{ + URL: "https://example.atlassian.net", + APIToken: "scoped-token", + AuthMethod: "bearer", + CloudID: "abc-123", + }) + testutil.RequireNoError(t, err) + testutil.Equal(t, "https://gateway.example/ex/jira/abc-123/rest/api/3", c.BaseURL) +} diff --git a/tools/jtk/docs/development.md b/tools/jtk/docs/development.md index d1376ba4..80f7218e 100644 --- a/tools/jtk/docs/development.md +++ b/tools/jtk/docs/development.md @@ -75,7 +75,7 @@ Read `internal/cmd/OUTPUT_SPEC.md` before changing default output, `--id`, `--ex `jtk` participates in the shared Atlassian credential/config model described by the monorepo guide. `ATLASSIAN_*` variables apply across both tools; `JIRA_*` variables override for jtk. The jtk-specific config section carries non-secret defaults such as `default_project`. -Basic auth uses an instance URL plus email and token. Bearer auth routes through `api.atlassian.com`, requires a cloud ID, and has Atlassian platform scope limitations for some Jira surfaces. +Basic auth uses an instance URL plus email and token. Bearer auth routes through `api.atlassian.com`, requires a cloud ID, and has Atlassian platform scope limitations for some Jira surfaces. Proxy auth uses only a URL, sends no `Authorization` header, and allows loopback `http://` URLs for trusted local proxies while keeping arbitrary cleartext proxy URLs rejected. Bearer gateway routing can be overridden with `JIRA_GATEWAY_BASE_URL` or `ATLASSIAN_GATEWAY_BASE_URL`. ## Testing Notes diff --git a/tools/jtk/integration-tests.md b/tools/jtk/integration-tests.md index badea002..932572e0 100644 --- a/tools/jtk/integration-tests.md +++ b/tools/jtk/integration-tests.md @@ -6,10 +6,11 @@ If a test reveals a bug, **record the bug and continue testing** rather than sto ## Auth Methods -jtk supports two authentication methods. The full integration test suite should be run with both: +jtk supports three authentication methods. The full integration test suite should be run with Basic Auth and Proxy Auth, and the bearer-compatible subset should be run with Bearer Auth: - **Basic Auth** (default): Classic API tokens using `email:token` against the instance URL. - **Bearer Auth**: Scoped API tokens for service accounts using `Authorization: Bearer ` against the `api.atlassian.com` gateway. +- **Proxy Auth**: Trusted proxy setup using `--auth-method proxy`; the CLI sends no `Authorization` header and requires only a URL. > **Scope limitations:** Scoped tokens don't have scopes for Agile (boards/sprints), Automation, or Dashboards. Sections 4 (Boards & Sprints), 6 (Dashboards), 8 (Automation), 13 (Dashboard Mutations), 14 (Automation Mutations), and 15 (Sprint Mutations) must be **skipped** when testing with Bearer Auth. Section 19 (Bearer Auth Guards) should be run **only** with Bearer Auth. @@ -30,6 +31,10 @@ jtk supports two authentication methods. The full integration test suite should - Your Cloud ID (find at `https://your-site.atlassian.net/_edge/tenant_info`) - `jtk init --auth-method bearer` completed +### Proxy Auth Prerequisites +- A trusted proxy that authenticates upstream and exposes Jira REST paths. +- `jtk init --auth-method proxy --url ` completed. + ### Build ```bash diff --git a/tools/jtk/internal/cmd/initcmd/initcmd.go b/tools/jtk/internal/cmd/initcmd/initcmd.go index 4cfa6689..1a145686 100644 --- a/tools/jtk/internal/cmd/initcmd/initcmd.go +++ b/tools/jtk/internal/cmd/initcmd/initcmd.go @@ -47,6 +47,10 @@ For service account scoped tokens (bearer auth): Use --auth-method bearer with your scoped API token and Cloud ID. Find your Cloud ID at: https://your-site.atlassian.net/_edge/tenant_info +For trusted local proxies: + Use --auth-method proxy with the proxy URL. The CLI sends no + Authorization header; the proxy is expected to authenticate upstream. + Scripted ingress (§1.5.1): use --token-stdin or --token-from-env VAR for the API token. The legacy --token flag is deprecated — it leaks the secret into shell history and process listings — and will be removed @@ -66,6 +70,9 @@ in a future release.`, jtk init --auth-method bearer --url https://mycompany.atlassian.net \ --token-from-env JIRA_API_TOKEN --cloud-id YOUR_CLOUD_ID + # Local proxy setup + jtk init --auth-method proxy --url http://127.0.0.1:8080/atlassian --no-verify + # Skip connection verification jtk init --no-verify`, RunE: func(cmd *cobra.Command, _ []string) error { @@ -78,7 +85,7 @@ in a future release.`, cmd.Flags().StringVar(&token, "token", "", "DEPRECATED: API token literal (use --token-stdin or --token-from-env; §1.5.1)") cmd.Flags().BoolVar(&tokenStdin, "token-stdin", false, "Read the API token from stdin (xor with --token-from-env)") cmd.Flags().StringVar(&tokenFromEnv, "token-from-env", "", "Read the API token from this env var (xor with --token-stdin)") - cmd.Flags().StringVar(&authMethod, "auth-method", "", "Authentication method: basic (default) or bearer") + cmd.Flags().StringVar(&authMethod, "auth-method", "", "Authentication method: basic (default), bearer, or proxy") cmd.Flags().StringVar(&cloudID, "cloud-id", "", "Atlassian Cloud ID (required for bearer auth)") cmd.Flags().BoolVar(&noVerify, "no-verify", false, "Skip connection verification") @@ -155,22 +162,25 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail, return err } cfg := result.prefill + isProxy := cfg.AuthMethod == auth.AuthMethodProxy - // Now the one-time §1.8 token migration (token-only, - // connection-preserving scrub). - if err := keyring.EnsureMigrated(); err != nil { - v.Error("Could not prepare secure credential storage: %v", err) - return err - } + if !isProxy { + // Now the one-time §1.8 token migration (token-only, + // connection-preserving scrub). + if err := keyring.EnsureMigrated(); err != nil { + v.Error("Could not prepare secure credential storage: %v", err) + return err + } - // EnsureMigrated relocated any legacy plaintext token into the - // keyring and scrubbed it from disk, so prefill.APIToken is empty - // even though the token still exists. Backfill from the keyring so a - // returning user isn't forced to re-enter a just-migrated token. - // NoMigrate: migration already ran. Value stays password-masked. - if cfg.APIToken == "" { - if tok, _, terr := keyring.ResolveTokenNoMigrate(credstore.ToolJTK); terr == nil { - cfg.APIToken = tok + // EnsureMigrated relocated any legacy plaintext token into the + // keyring and scrubbed it from disk, so prefill.APIToken is empty + // even though the token still exists. Backfill from the keyring so a + // returning user isn't forced to re-enter a just-migrated token. + // NoMigrate: migration already ran. Value stays password-masked. + if cfg.APIToken == "" { + if tok, _, terr := keyring.ResolveTokenNoMigrate(credstore.ToolJTK); terr == nil { + cfg.APIToken = tok + } } } @@ -180,7 +190,28 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail, // Build the form based on auth method var formGroups []*huh.Group - if isBearer { + if isProxy { + // Proxy auth: URL only. The proxy injects authentication upstream. + formGroups = append(formGroups, huh.NewGroup( + huh.NewInput(). + Title("Jira Proxy URL"). + Description("Trusted proxy URL; loopback http or https"). + Placeholder("http://127.0.0.1:8080/atlassian"). + Value(&cfg.URL). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("URL is required") + } + return nil + }), + + huh.NewInput(). + Title("Default Project (optional)"). + Description("Default project key for commands"). + Placeholder("MYPROJ"). + Value(&cfg.DefaultProject), + )) + } else if isBearer { // Bearer auth: URL + token + cloud ID (no email) formGroups = append(formGroups, huh.NewGroup( huh.NewInput(). @@ -275,7 +306,7 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail, // can't run — every required value must already be in cfg from the // flag prefills. Fail loud naming the first missing field. if !prompt.WantPrompt(opts.NonInteractive, opts.Stdin) { - if err := requireNonInteractiveFields(cfg, isBearer); err != nil { + if err := requireNonInteractiveFields(cfg); err != nil { return err } } else { @@ -287,22 +318,23 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail, // Normalize URL cfg.URL = sharedurl.NormalizeURL(cfg.URL) + normalizeAuthConfig(cfg) + + client, err := api.New(api.ClientConfig{ + URL: cfg.URL, + Email: cfg.Email, + APIToken: cfg.APIToken, + AuthMethod: cfg.AuthMethod, + CloudID: cfg.CloudID, + }) + if err != nil { + return fmt.Errorf("creating client: %w", err) + } // Verify connection unless --no-verify if !noVerify { v.Println("Testing connection...") - client, err := api.New(api.ClientConfig{ - URL: cfg.URL, - Email: cfg.Email, - APIToken: cfg.APIToken, - AuthMethod: cfg.AuthMethod, - CloudID: cfg.CloudID, - }) - if err != nil { - return fmt.Errorf("creating client: %w", err) - } - user, err := client.GetCurrentUser(ctx, "") if err != nil { v.Error("Connection failed: %v", err) @@ -348,16 +380,19 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail, return fmt.Errorf("saving shared store: %w", err) } - // The token never lands in the plaintext store (Save strips it) — it - // goes to the OS keyring under the single shared api_token (§1.11.10: - // one key for both jtk and cfl; the reconcile write-target governs - // only NON-secret placement, untouched here). - if err := keyring.PersistToken(cfg.APIToken); err != nil { - v.Error("Saved the non-secret config to %s, but could not store the API token in the keyring: %v", sharedPath, err) - v.Error("Recover by storing just the token (no need to re-run init): `jtk set-credential --ref atlassian-cli/default --key api_token --stdin --overwrite` (reads stdin; use --from-env VAR for env-driven setup).") - return err + // Direct auth tokens never land in the plaintext store (Save strips them). + // Proxy auth has no CLI-side token; basic/bearer tokens go to the OS + // keyring under the single shared api_token (§1.11.10). + if cfg.AuthMethod == auth.AuthMethodProxy { + v.Success("Configuration saved to %s (proxy auth; no token stored)", sharedPath) + } else { + if err := keyring.PersistToken(cfg.APIToken); err != nil { + v.Error("Saved the non-secret config to %s, but could not store the API token in the keyring: %v", sharedPath, err) + v.Error("Recover by storing just the token (no need to re-run init): `jtk set-credential --ref atlassian-cli/default --key api_token --stdin --overwrite` (reads stdin; use --from-env VAR for env-driven setup).") + return err + } + v.Success("Configuration saved to %s (token stored in the OS keyring)", sharedPath) } - v.Success("Configuration saved to %s (token stored in the OS keyring)", sharedPath) for _, lp := range result.consumedLegacies { if !prompt.WantPrompt(opts.NonInteractive, opts.Stdin) { @@ -394,25 +429,42 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail, if isBearer { v.Println("") v.Info("To switch back to basic auth later, run: jtk init --auth-method basic") + } else if isProxy { + v.Println("") + v.Info("To switch to direct authentication later, run: jtk init --auth-method basic") } return nil } +func normalizeAuthConfig(cfg *config.Config) { + if cfg.AuthMethod == "" { + cfg.AuthMethod = auth.AuthMethodBasic + } + if cfg.AuthMethod == auth.AuthMethodProxy { + cfg.Email = "" + cfg.APIToken = "" + cfg.CloudID = "" + } +} + // requireNonInteractiveFields enforces the §3.4 fail-loud contract for // scripted/CI runs of `jtk init`: any required value missing from the // flag prefills (which already populated cfg) produces an error naming // the first missing field, with the auth-mode shape baked into the // message so the operator knows which flag set is required. -func requireNonInteractiveFields(cfg *config.Config, isBearer bool) error { +func requireNonInteractiveFields(cfg *config.Config) error { if cfg.URL == "" { return fmt.Errorf("--non-interactive: missing required value for --url") } - if isBearer { + switch cfg.AuthMethod { + case auth.AuthMethodProxy: + return nil + case auth.AuthMethodBearer: if cfg.CloudID == "" { return fmt.Errorf("--non-interactive: missing required value for --cloud-id (bearer auth)") } - } else { + default: if cfg.Email == "" { return fmt.Errorf("--non-interactive: missing required value for --email (basic auth)") } diff --git a/tools/jtk/internal/cmd/initcmd/initcmd_test.go b/tools/jtk/internal/cmd/initcmd/initcmd_test.go index 4c43b707..5e6573ea 100644 --- a/tools/jtk/internal/cmd/initcmd/initcmd_test.go +++ b/tools/jtk/internal/cmd/initcmd/initcmd_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/open-cli-collective/atlassian-go/auth" "github.com/open-cli-collective/atlassian-go/credstore" "github.com/open-cli-collective/atlassian-go/credtest" "github.com/open-cli-collective/atlassian-go/keyring" @@ -73,10 +74,9 @@ func TestRequireNonInteractiveFields_NamesFirstMissing(t *testing.T) { t.Parallel() tests := []struct { - name string - cfg *config.Config - isBearer bool - wants []string + name string + cfg *config.Config + wants []string }{ { name: "basic auth — missing URL", @@ -89,10 +89,9 @@ func TestRequireNonInteractiveFields_NamesFirstMissing(t *testing.T) { wants: []string{"--email"}, }, { - name: "bearer — missing cloud-id", - cfg: &config.Config{URL: "https://acme.atlassian.net"}, - isBearer: true, - wants: []string{"--cloud-id"}, + name: "bearer — missing cloud-id", + cfg: &config.Config{URL: "https://acme.atlassian.net", AuthMethod: auth.AuthMethodBearer}, + wants: []string{"--cloud-id"}, }, { name: "basic auth — missing token recommends --token-stdin + --token-from-env + set-credential", @@ -100,16 +99,15 @@ func TestRequireNonInteractiveFields_NamesFirstMissing(t *testing.T) { wants: []string{"--token-stdin", "--token-from-env", "set-credential"}, }, { - name: "bearer — missing token recommends --token-stdin + --token-from-env + set-credential", - cfg: &config.Config{URL: "https://acme.atlassian.net", CloudID: "cid"}, - isBearer: true, - wants: []string{"--token-stdin", "--token-from-env", "set-credential"}, + name: "bearer — missing token recommends --token-stdin + --token-from-env + set-credential", + cfg: &config.Config{URL: "https://acme.atlassian.net", CloudID: "cid", AuthMethod: auth.AuthMethodBearer}, + wants: []string{"--token-stdin", "--token-from-env", "set-credential"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - err := requireNonInteractiveFields(tc.cfg, tc.isBearer) + err := requireNonInteractiveFields(tc.cfg) testutil.RequireError(t, err) if !strings.Contains(err.Error(), "--non-interactive: missing") { t.Fatalf("error must mention --non-interactive prefix: %v", err) @@ -131,7 +129,18 @@ func TestRequireNonInteractiveFields_AllSupplied_NoError(t *testing.T) { URL: "https://acme.atlassian.net", Email: "u@x.io", APIToken: "tok-1234567890", } - if err := requireNonInteractiveFields(cfg, false); err != nil { + if err := requireNonInteractiveFields(cfg); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRequireNonInteractiveFields_ProxyNeedsOnlyURL(t *testing.T) { + t.Parallel() + cfg := &config.Config{ + URL: "http://127.0.0.1:8080/atlassian", + AuthMethod: auth.AuthMethodProxy, + } + if err := requireNonInteractiveFields(cfg); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -181,6 +190,34 @@ func TestRunInit_NonInteractive_MissingToken_FlagAndKeyringEmpty(t *testing.T) { } } +func TestRunInit_Proxy_NoTokenRequiredOrPersisted(t *testing.T) { + credtest.Hermetic(t) + opts := &root.Options{ + NoColor: true, + NonInteractive: true, + Stdin: strings.NewReader(""), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + } + err := runInit(context.Background(), opts, + "http://127.0.0.1:8080/atlassian", "", "", false, "", auth.AuthMethodProxy, "", true) + testutil.RequireNoError(t, err) + + store, err := credstore.Load(credtest.SharedConfigPath(t)) + testutil.RequireNoError(t, err) + testutil.Equal(t, "http://127.0.0.1:8080/atlassian", store.Default.URL) + testutil.Equal(t, "", store.Default.Email) + testutil.Equal(t, auth.AuthMethodProxy, store.Default.AuthMethod) + testutil.Equal(t, "", store.Default.CloudID) + + s, err := keyring.OpenNoMigrate() + testutil.RequireNoError(t, err) + defer func() { _ = s.Close() }() + ok, err := s.HasToken(keyring.KeyAPIToken) + testutil.RequireNoError(t, err) + testutil.False(t, ok) +} + func TestInitCommand_Flags(t *testing.T) { t.Parallel() rootCmd := &cobra.Command{Use: "jtk", Short: "Test CLI"} diff --git a/tools/jtk/internal/cmd/root/root.go b/tools/jtk/internal/cmd/root/root.go index 175358f6..a3c4b7ff 100644 --- a/tools/jtk/internal/cmd/root/root.go +++ b/tools/jtk/internal/cmd/root/root.go @@ -12,6 +12,7 @@ import ( cccredstore "github.com/open-cli-collective/cli-common/credstore" "github.com/open-cli-collective/atlassian-go/artifact" + "github.com/open-cli-collective/atlassian-go/auth" "github.com/open-cli-collective/atlassian-go/keyring" "github.com/open-cli-collective/atlassian-go/present" "github.com/open-cli-collective/atlassian-go/version" @@ -93,16 +94,21 @@ func (o *Options) APIClient() (*api.Client, error) { if o.cachedClient != nil { return o.cachedClient, nil } - token, err := config.ResolveAPIToken() - if err != nil { - return nil, err + authMethod := config.GetAuthMethod() + var token string + if authMethod != auth.AuthMethodProxy { + var err error + token, err = config.ResolveAPIToken() + if err != nil { + return nil, err + } } c, err := api.New(api.ClientConfig{ URL: config.GetURL(), Email: config.GetEmail(), APIToken: token, Verbose: o.Verbose, - AuthMethod: config.GetAuthMethod(), + AuthMethod: authMethod, CloudID: config.GetCloudID(), }) if err != nil { diff --git a/tools/jtk/internal/cmd/root/root_test.go b/tools/jtk/internal/cmd/root/root_test.go index d12dd212..fcfbc5c5 100644 --- a/tools/jtk/internal/cmd/root/root_test.go +++ b/tools/jtk/internal/cmd/root/root_test.go @@ -2,11 +2,14 @@ package root import ( "bytes" + "net/http" + "net/http/httptest" "regexp" "strings" "testing" "github.com/open-cli-collective/atlassian-go/artifact" + "github.com/open-cli-collective/atlassian-go/credtest" "github.com/open-cli-collective/atlassian-go/present" "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/atlassian-go/view" @@ -124,6 +127,29 @@ func TestOptions_SetAPIClient(t *testing.T) { testutil.Equal(t, got, client) } +func TestOptions_APIClient_ProxySkipsToken(t *testing.T) { + credtest.Hermetic(t) + var capturedAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + t.Setenv("JIRA_URL", server.URL) + t.Setenv("JIRA_AUTH_METHOD", "proxy") + + opts := &Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + c, err := opts.APIClient() + testutil.RequireNoError(t, err) + testutil.Equal(t, "", c.GetAuthHeader()) + + _, err = c.Get(t.Context(), "/myself") + testutil.RequireNoError(t, err) + testutil.Equal(t, "", capturedAuth) +} + func TestRegisterCommands(t *testing.T) { cmd, opts := NewCmd() diff --git a/tools/jtk/internal/config/config.go b/tools/jtk/internal/config/config.go index a2e1d7e8..e7a955b3 100644 --- a/tools/jtk/internal/config/config.go +++ b/tools/jtk/internal/config/config.go @@ -76,7 +76,7 @@ type Config struct { Email string `json:"email"` APIToken string `json:"api_token"` DefaultProject string `json:"default_project,omitempty"` - AuthMethod string `json:"auth_method,omitempty"` // "basic" (default) or "bearer" + AuthMethod string `json:"auth_method,omitempty"` // "basic" (default), "bearer", or "proxy" CloudID string `json:"cloud_id,omitempty"` // Required for bearer auth (gateway URL) // Keyring's `omitempty` is a no-op on this struct type — encoding/json // emits an empty {} for zero-valued struct fields regardless. Kept for @@ -269,16 +269,19 @@ func GetAPIToken() string { return tok } -// IsConfigured returns true if the NON-SECRET config is complete and a -// token is resolvable (env or keyring, non-migrating). The token left -// the plaintext config store, so completeness is composed from both -// halves. For bearer auth: URL + Cloud ID + token; for basic: URL + -// email + token. +// IsConfigured returns true if the config is complete for the selected auth +// method. For proxy auth, only the URL is required because credentials are +// injected by the proxy. For bearer auth: URL + Cloud ID + token. For basic: +// URL + email + token. func IsConfigured() bool { - if GetAuthMethod() == auth.AuthMethodBearer { + switch GetAuthMethod() { + case auth.AuthMethodProxy: + return GetURL() != "" + case auth.AuthMethodBearer: return GetURL() != "" && GetCloudID() != "" && GetAPIToken() != "" + default: + return GetURL() != "" && GetEmail() != "" && GetAPIToken() != "" } - return GetURL() != "" && GetEmail() != "" && GetAPIToken() != "" } // GetAuthMethod returns the auth method from config or environment. diff --git a/tools/jtk/internal/config/config_test.go b/tools/jtk/internal/config/config_test.go index ba460ffe..0b9680d6 100644 --- a/tools/jtk/internal/config/config_test.go +++ b/tools/jtk/internal/config/config_test.go @@ -485,6 +485,17 @@ func TestIsConfigured_Bearer(t *testing.T) { testutil.True(t, IsConfigured()) } +func TestIsConfigured_Proxy(t *testing.T) { + _, cleanup := setupTestConfig(t) + defer cleanup() + + t.Setenv("JIRA_AUTH_METHOD", "proxy") + testutil.False(t, IsConfigured()) + + t.Setenv("JIRA_URL", "http://127.0.0.1:8080/atlassian") + testutil.True(t, IsConfigured()) +} + func TestConfig_SaveAndLoad_WithAuthFields(t *testing.T) { _, cleanup := setupTestConfig(t) defer cleanup()