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

// NormalizeConfig applies auth-method policy to config credential fields.
//
// Empty auth method defaults to basic. Proxy auth sends no CLI-side
// credentials, so direct credential fields are cleared.
func NormalizeConfig(authMethod, email, apiToken, cloudID string) (string, string, string, string) {
if authMethod == "" {
authMethod = AuthMethodBasic
}
if authMethod == AuthMethodProxy {
email = ""
apiToken = ""
cloudID = ""
}
return authMethod, email, apiToken, cloudID
}

// RequireNonInteractiveFields 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 RequireNonInteractiveFields(url, authMethod, email, apiToken, cloudID, toolHint string) error {
if url == "" {
return errors.New("--non-interactive: missing required value for --url")
}

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

if 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
163 changes: 163 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,165 @@ 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()

gotAuthMethod, gotEmail, gotAPIToken, gotCloudID := NormalizeConfig(
tt.authMethod, tt.email, tt.apiToken, tt.cloudID,
)

if gotAuthMethod != tt.wantAuthMethod {
t.Errorf("auth method = %q, want %q", gotAuthMethod, tt.wantAuthMethod)
}
if gotEmail != tt.wantEmail {
t.Errorf("email = %q, want %q", gotEmail, tt.wantEmail)
}
if gotAPIToken != tt.wantAPIToken {
t.Errorf("api token = %q, want %q", gotAPIToken, tt.wantAPIToken)
}
if gotCloudID != tt.wantCloudID {
t.Errorf("cloud ID = %q, want %q", gotCloudID, 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 := RequireNonInteractiveFields(
tt.url, tt.authMethod, tt.email, tt.apiToken, tt.cloudID, 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 +307,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 @@ -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")

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 @@ -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)
Expand All @@ -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) {
Expand Down
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