Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions shared/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -604,3 +604,21 @@ func TestOptions_timeoutOrDefault(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)
}
}
21 changes: 21 additions & 0 deletions shared/client/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package client

import (
"io"
"os"
"strings"
"time"
)

Expand All @@ -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.
Expand All @@ -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.
Expand Down
43 changes: 42 additions & 1 deletion shared/url/url.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
// Package url provides URL normalization utilities for Atlassian CLI tools.
package url

import "strings"
import (
"errors"
"net/netip"
neturl "net/url"
"strings"
)

// ErrRequiresHTTPS is returned for a cleartext non-loopback URL.
var ErrRequiresHTTPS = errors.New("url must use https unless it is loopback http")

// RequireSecureOrLoopback permits HTTPS endpoints and local HTTP endpoints.
// Scheme-less URLs are normalized to HTTPS, as they are by API clients.
func RequireSecureOrLoopback(u string) error {
if strings.Contains(u, "://") && !HasScheme(u) {
return ErrRequiresHTTPS
}
u = NormalizeURL(u)
parsed, err := neturl.Parse(u)
if err != nil || parsed.Host == "" {
return ErrRequiresHTTPS
}
if parsed.Scheme == "https" || IsLoopbackHTTP(u) {
return nil
}
return ErrRequiresHTTPS
}

// NormalizeURL ensures the URL has an https scheme and no trailing slashes.
// If the URL is empty, it returns an empty string.
Expand Down Expand Up @@ -36,3 +61,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()
}
62 changes: 61 additions & 1 deletion shared/url/url_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,38 @@
package url //nolint:revive // test file for url package

import "testing"
import (
"errors"
"testing"
)

func TestRequireSecureOrLoopback(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
url string
valid bool
}{
{"https://proxy.example.com/path", true},
{"proxy.example.com/path", true},
{"http://localhost:8080/path", true},
{"http://127.0.0.1:8080/path", true},
{"http://[::1]:8080/path", true},
{"http://proxy.example.com/path", false},
{"ftp://proxy.example.com/path", false},
{"https://", false},
{"", false},
} {
t.Run(tt.url, func(t *testing.T) {
t.Parallel()
err := RequireSecureOrLoopback(tt.url)
if tt.valid && err != nil {
t.Fatalf("RequireSecureOrLoopback(%q) = %v, want nil", tt.url, err)
}
if !tt.valid && !errors.Is(err, ErrRequiresHTTPS) {
t.Fatalf("RequireSecureOrLoopback(%q) = %v, want ErrRequiresHTTPS", tt.url, err)
}
})
}
}

func TestNormalizeURL(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -115,3 +147,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)
}
})
}
}
3 changes: 3 additions & 0 deletions tools/cfl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,9 @@ 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` |

Gateway overrides must use HTTPS, except for loopback HTTP endpoints used in local testing.

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`.

Expand Down
58 changes: 53 additions & 5 deletions tools/cfl/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ 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.
// Validation errors for auth configuration.
var (
ErrAPITokenRequired = errors.New("API token is required")
ErrCloudIDRequired = errors.New("cloud ID is required for bearer auth")
ErrURLRequired = errors.New("URL is required")
ErrEmailRequired = errors.New("email is required")
)

// Client is the Confluence Cloud API client.
Expand All @@ -25,6 +28,40 @@ type Client struct {
*client.Client
}

// ClientConfig contains the inputs for a Confluence API client.
type ClientConfig struct {
URL string
Email string
APIToken string
AuthMethod string
CloudID string
GatewayBaseURL string // Optional bearer gateway base; defaults to api.atlassian.com
}

// New dispatches all supported authentication methods through one entry point.
func New(cfg ClientConfig) (*Client, error) {
if cfg.AuthMethod != "" {
if err := auth.ValidateAuthMethod(cfg.AuthMethod); err != nil {
return nil, err
}
}
switch cfg.AuthMethod {
case auth.AuthMethodBearer:
return newBearerClient(cfg)
default:
if cfg.URL == "" {
return nil, ErrURLRequired
}
if cfg.Email == "" {
return nil, ErrEmailRequired
}
if cfg.APIToken == "" {
return nil, ErrAPITokenRequired
}
return NewClient(cfg.URL, cfg.Email, cfg.APIToken), nil
}
}

// NewClient creates a new Confluence API client using basic auth.
func NewClient(baseURL, email, apiToken string) *Client {
return &Client{
Expand All @@ -35,15 +72,26 @@ func NewClient(baseURL, email, apiToken string) *Client {
// 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) {
if apiToken == "" {
return New(ClientConfig{APIToken: apiToken, CloudID: cloudID, AuthMethod: auth.AuthMethodBearer})
}

func newBearerClient(cfg ClientConfig) (*Client, error) {
if cfg.APIToken == "" {
return nil, ErrAPITokenRequired
}
if cloudID == "" {
if cfg.CloudID == "" {
return nil, ErrCloudIDRequired
}
gatewayBase := fmt.Sprintf("%s/ex/confluence/%s/wiki", client.GatewayBaseURL, cloudID)
gatewayURL := cfg.GatewayBaseURL
if gatewayURL == "" {
gatewayURL = client.GatewayBaseURL
}
if err := sharedurl.RequireSecureOrLoopback(gatewayURL); err != nil {
return nil, fmt.Errorf("invalid gateway base URL: %w", err)
}
gatewayBase := fmt.Sprintf("%s/ex/confluence/%s/wiki", sharedurl.NormalizeURL(gatewayURL), cfg.CloudID)
opts := &client.Options{
AuthHeader: auth.BearerAuthHeader(apiToken),
AuthHeader: auth.BearerAuthHeader(cfg.APIToken),
}
return &Client{
Client: client.New(gatewayBase, "", "", opts),
Expand Down
21 changes: 21 additions & 0 deletions tools/cfl/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/open-cli-collective/atlassian-go/client"
"github.com/open-cli-collective/atlassian-go/testutil"
sharedurl "github.com/open-cli-collective/atlassian-go/url"
)

func TestNewClient(t *testing.T) {
Expand Down Expand Up @@ -243,3 +244,23 @@ func TestNewBearerClient(t *testing.T) {
testutil.Equal(t, "https://example.atlassian.net/wiki", c.GetBaseURL())
})
}

func TestNew_BearerGatewayOverride(t *testing.T) {
c, err := New(ClientConfig{APIToken: "scoped-token", CloudID: "abc-123", AuthMethod: "bearer", GatewayBaseURL: "https://gateway.example/"})
testutil.RequireNoError(t, err)
testutil.Equal(t, "https://gateway.example/ex/confluence/abc-123/wiki", c.GetBaseURL())
}

func TestNew_BearerRejectsCleartextGateway(t *testing.T) {
c, err := New(ClientConfig{APIToken: "token", CloudID: "cloud", AuthMethod: "bearer", GatewayBaseURL: "http://external.example"})
if !errors.Is(err, sharedurl.ErrRequiresHTTPS) || c != nil {
t.Fatalf("New() = (%v, %v), want secure URL error", c, err)
}
}

func TestNew_BearerDoesNotReadGatewayEnv(t *testing.T) {
t.Setenv("CFL_GATEWAY_BASE_URL", "https://ambient.example")
c, err := New(ClientConfig{APIToken: "token", CloudID: "cloud", AuthMethod: "bearer"})
testutil.RequireNoError(t, err)
testutil.Equal(t, "https://api.atlassian.com/ex/confluence/cloud/wiki", c.GetBaseURL())
}
2 changes: 2 additions & 0 deletions tools/cfl/docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ Add new Confluence macros through `MacroRegistry` in `macro.go`; the tokenizer,

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.

Bearer gateway routing can be overridden with `CFL_GATEWAY_BASE_URL` or `ATLASSIAN_GATEWAY_BASE_URL`; overrides must use HTTPS or loopback HTTP. Gateway values are resolved by commands and passed explicitly to API clients.

## Output

`cfl` is markdown-first for page content. The target text-output contract lives in `internal/cmd/OUTPUT_SPEC.md`. The presenter migration guide lives in `internal/present/README.md` and records the cfl-specific command/presenter/renderer boundaries for #271. Resource `-o json` is removed; JSON is reserved for control-plane envelopes documented by the shared standards.
Expand Down
12 changes: 7 additions & 5 deletions tools/cfl/internal/cmd/init/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"

"github.com/open-cli-collective/atlassian-go/auth"
"github.com/open-cli-collective/atlassian-go/client"
"github.com/open-cli-collective/atlassian-go/credstore"
"github.com/open-cli-collective/atlassian-go/keyring"
"github.com/open-cli-collective/atlassian-go/prompt"
Expand All @@ -23,14 +24,15 @@ 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 the bearer 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)
}
return api.NewClient(cfg.URL, cfg.Email, cfg.APIToken), nil
return api.New(api.ClientConfig{
URL: cfg.URL, Email: cfg.Email, APIToken: cfg.APIToken,
AuthMethod: cfg.AuthMethod, CloudID: cfg.CloudID,
GatewayBaseURL: client.GatewayBaseURLFromEnv("CFL_GATEWAY_BASE_URL"),
})
}

// Register adds the init command to the root command.
Expand Down
11 changes: 6 additions & 5 deletions tools/cfl/internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,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/client"
"github.com/open-cli-collective/atlassian-go/keyring"
"github.com/open-cli-collective/atlassian-go/present"
"github.com/open-cli-collective/atlassian-go/version"
Expand Down Expand Up @@ -141,10 +141,11 @@ func (o *Options) APIClient() (*api.Client, error) {
if err != nil {
return nil, err
}
if cfg.AuthMethod == auth.AuthMethodBearer {
return api.NewBearerClient(cfg.APIToken, cfg.CloudID)
}
return api.NewClient(cfg.URL, cfg.Email, cfg.APIToken), nil
return api.New(api.ClientConfig{
URL: cfg.URL, Email: cfg.Email, APIToken: cfg.APIToken,
AuthMethod: cfg.AuthMethod, CloudID: cfg.CloudID,
GatewayBaseURL: client.GatewayBaseURLFromEnv("CFL_GATEWAY_BASE_URL"),
})
}

// SetAPIClient sets a test client (for testing only)
Expand Down
9 changes: 9 additions & 0 deletions tools/cfl/internal/cmd/root/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,3 +450,12 @@ func TestDefaultConfigLayersSharedValues(t *testing.T) {
t.Fatalf("Execute failed: %v", err)
}
}

func TestOptions_APIClient_UsesGatewayOverride(t *testing.T) {
t.Setenv("CFL_GATEWAY_BASE_URL", "https://gateway.example/")
_, opts := NewCmd()
opts.SetConfig(&config.Config{URL: "https://example.atlassian.net/wiki", APIToken: "token", AuthMethod: auth.AuthMethodBearer, CloudID: "cloud-123"})
c, err := opts.APIClient()
testutil.RequireNoError(t, err)
testutil.Equal(t, "https://gateway.example/ex/confluence/cloud-123/wiki", c.GetBaseURL())
}
Loading