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
56 changes: 54 additions & 2 deletions shared/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,73 @@ 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)
}
}

// Credentials groups fields governed by auth-method policy.
type Credentials struct {
Method string
Email string
APIToken string
CloudID string
}

// Normalize defaults an empty method to basic and clears direct credentials
// for proxy auth, which sends no CLI-side credentials.
func (c Credentials) Normalize() Credentials {
if c.Method == "" {
c.Method = AuthMethodBasic
}
if c.Method == AuthMethodProxy {
c.Email = ""
c.APIToken = ""
c.CloudID = ""
}
return c
}

// RequireNonInteractive validates the auth fields needed by scripted
// init flows and names the first missing CLI value. toolHint is the
// tool-specific set-credential command shown when the API token is absent.
func (c Credentials) RequireNonInteractive(url, toolHint string) error {
if url == "" {
return errors.New("--non-interactive: missing required value for --url")
}

switch c.Method {
case AuthMethodProxy:
return nil
case AuthMethodBearer:
if c.CloudID == "" {
return errors.New("--non-interactive: missing required value for --cloud-id (bearer auth)")
}
default:
if c.Email == "" {
return errors.New("--non-interactive: missing required value for --email (basic auth)")
}
}

if c.APIToken == "" {
return fmt.Errorf("--non-interactive: missing required value for --token-stdin or --token-from-env VAR (or pre-stage with `%s`)", toolHint)
}
return nil
}

// BasicAuthHeader returns the HTTP Basic Authentication header value
// for use with Atlassian Cloud APIs.
//
Expand Down
159 changes: 159 additions & 0 deletions shared/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -139,6 +140,161 @@ func TestValidateAuthMethod(t *testing.T) {
}
}

func TestNormalizeConfig(t *testing.T) {
t.Parallel()

tests := []struct {
name string
authMethod string
email string
apiToken string
cloudID string
wantAuthMethod string
wantEmail string
wantAPIToken string
wantCloudID string
}{
{
name: "empty auth method defaults to basic",
email: "user@example.com",
apiToken: "token",
cloudID: "cloud-id",
wantAuthMethod: AuthMethodBasic,
wantEmail: "user@example.com",
wantAPIToken: "token",
wantCloudID: "cloud-id",
},
{
name: "basic auth keeps direct credentials",
authMethod: AuthMethodBasic,
email: "user@example.com",
apiToken: "token",
cloudID: "cloud-id",
wantAuthMethod: AuthMethodBasic,
wantEmail: "user@example.com",
wantAPIToken: "token",
wantCloudID: "cloud-id",
},
{
name: "bearer auth keeps token and cloud ID",
authMethod: AuthMethodBearer,
email: "user@example.com",
apiToken: "token",
cloudID: "cloud-id",
wantAuthMethod: AuthMethodBearer,
wantEmail: "user@example.com",
wantAPIToken: "token",
wantCloudID: "cloud-id",
},
{
name: "proxy auth strips direct credentials",
authMethod: AuthMethodProxy,
email: "user@example.com",
apiToken: "token",
cloudID: "cloud-id",
wantAuthMethod: AuthMethodProxy,
wantEmail: "",
wantAPIToken: "",
wantCloudID: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got := (Credentials{Method: tt.authMethod, Email: tt.email, APIToken: tt.apiToken, CloudID: tt.cloudID}).Normalize()

if got.Method != tt.wantAuthMethod {
t.Errorf("auth method = %q, want %q", got.Method, tt.wantAuthMethod)
}
if got.Email != tt.wantEmail {
t.Errorf("email = %q, want %q", got.Email, tt.wantEmail)
}
if got.APIToken != tt.wantAPIToken {
t.Errorf("api token = %q, want %q", got.APIToken, tt.wantAPIToken)
}
if got.CloudID != tt.wantCloudID {
t.Errorf("cloud ID = %q, want %q", got.CloudID, tt.wantCloudID)
}
})
}
}

func TestRequireNonInteractiveFields(t *testing.T) {
t.Parallel()

const toolHint = "cfl set-credential --ref atlassian-cli/default --key api_token --stdin"
tests := []struct {
name string
url string
authMethod string
email string
apiToken string
cloudID string
wantError string
}{
{name: "missing URL", wantError: "--url"},
{
name: "basic auth missing email",
url: "https://example.atlassian.net",
apiToken: "token",
wantError: "--email (basic auth)",
},
{
name: "bearer auth missing cloud ID",
url: "https://example.atlassian.net",
authMethod: AuthMethodBearer,
apiToken: "token",
wantError: "--cloud-id (bearer auth)",
},
{
name: "basic auth missing token includes tool hint",
url: "https://example.atlassian.net",
email: "user@example.com",
wantError: toolHint,
},
{
name: "bearer auth missing token includes tool hint",
url: "https://example.atlassian.net",
authMethod: AuthMethodBearer,
cloudID: "cloud-id",
wantError: toolHint,
},
{
name: "proxy auth needs only URL",
url: "http://127.0.0.1:8080/atlassian",
authMethod: AuthMethodProxy,
},
{
name: "complete basic auth",
url: "https://example.atlassian.net",
email: "user@example.com",
apiToken: "token",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := (Credentials{Method: tt.authMethod, Email: tt.email, APIToken: tt.apiToken, CloudID: tt.cloudID}).RequireNonInteractive(tt.url, toolHint)
if tt.wantError == "" {
if err != nil {
t.Fatalf("RequireNonInteractiveFields() error = %v, want nil", err)
}
return
}
if err == nil {
t.Fatalf("RequireNonInteractiveFields() error = nil, want containing %q", tt.wantError)
}
if !strings.Contains(err.Error(), tt.wantError) {
t.Fatalf("RequireNonInteractiveFields() error = %q, want containing %q", err, tt.wantError)
}
})
}
}

func TestAuthMethodConstants(t *testing.T) {
t.Parallel()
if AuthMethodBasic != "basic" {
Expand All @@ -147,4 +303,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")
}
}
8 changes: 6 additions & 2 deletions shared/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}

Expand Down Expand Up @@ -120,7 +122,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")

Expand Down
39 changes: 39 additions & 0 deletions shared/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,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)
Expand Down Expand Up @@ -604,3 +625,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
12 changes: 12 additions & 0 deletions shared/credstore/conndivergence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading