Skip to content
Merged
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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ go test -v ./internal/output/... -run TestHumanFormatter

### Core Packages

- `internal/auth/` - Authentication provider supporting two modes. JWT (priority): client credentials exchange at `/api/v1/authenticate`, auto-refresh 5min before expiry, tenant ID extracted from `customer_id` JWT claim. Basic (fallback): static token + explicit tenant ID. Implements `AuthHeaderProvider` interface used by the API client.
- `internal/auth/` - Authentication provider supporting two modes. JWT (priority): client credentials exchange at `/api/v1/auth/token`, auto-refresh 5min before expiry, tenant ID extracted from `customer_id` JWT claim. Basic (fallback): static token + explicit tenant ID. Implements `AuthHeaderProvider` interface used by the API client.
- `internal/api/` - API client for Armis Cloud. Two HTTP clients: one for general calls (60s timeout), one for uploads (streaming, no timeout, no retry). Functional options pattern (`WithHTTPClient()`, `WithUploadHTTPClient()`, `WithAllowLocalURLs()`). Upload uses `io.Pipe` streaming to avoid OOM on large files. Enforces HTTPS, validates presigned S3 URLs against SSRF.
- `internal/model/` - Data structures: `Finding` (23 fields), `ScanResult`, `Summary`, `Fix`, `FindingValidation` (with taint/reachability analysis), API response types (`NormalizedFinding`, pagination).
- `internal/output/` - Output formatters (human, json, sarif, junit) implementing the `Formatter` interface. `styles.go` defines ~50 lipgloss styles using Tailwind CSS color palette. `icons.go` defines Unicode constants (severity dots, box-drawing chars). `SyncColors()` switches between full-color and plain styles based on `cli.ColorsEnabled()`.
Expand Down Expand Up @@ -83,9 +83,10 @@ go test -v ./internal/output/... -run TestHumanFormatter

- `ARMIS_CLIENT_ID` - Client ID for JWT authentication (recommended)
- `ARMIS_CLIENT_SECRET` - Client secret for JWT authentication
- `ARMIS_AUTH_ENDPOINT` - JWT authentication service endpoint URL
- `ARMIS_API_TOKEN` - API token for Basic authentication (fallback)
- `ARMIS_TENANT_ID` - Tenant identifier (required only with Basic auth; JWT extracts it from token)
- `ARMIS_API_URL` - Override base URL for Armis API (advanced; defaults based on --dev flag)
- `ARMIS_REGION` - Override Armis cloud region (equivalent to `--region`; used for region-aware authentication)
- `ARMIS_FORMAT` - Default output format
- `ARMIS_PAGE_LIMIT` - Results pagination size
- `ARMIS_THEME` - Terminal background theme: auto, dark, light (default: auto)
Expand Down
2 changes: 1 addition & 1 deletion docs/CI-INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,7 @@ Configure `ARMIS_API_TOKEN` and `ARMIS_TENANT_ID` as [secured repository variabl
#### "authentication required"

- No valid authentication credentials were provided
- Set `ARMIS_API_TOKEN` and `ARMIS_TENANT_ID` environment variables or secrets
- Set `ARMIS_CLIENT_ID` and `ARMIS_CLIENT_SECRET` for JWT auth (recommended), or `ARMIS_API_TOKEN` and `ARMIS_TENANT_ID` for legacy auth

#### "tenant ID required"

Expand Down
3 changes: 2 additions & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,10 @@ armis-cli scan repo . \
|----------|-------------|
| `ARMIS_CLIENT_ID` | Client ID for JWT authentication |
| `ARMIS_CLIENT_SECRET` | Client secret for JWT authentication |
| `ARMIS_AUTH_ENDPOINT` | Authentication service endpoint URL |
| `ARMIS_API_TOKEN` | API token for Basic authentication |
| `ARMIS_TENANT_ID` | Tenant identifier (required for Basic auth only) |
| `ARMIS_API_URL` | Override base URL for Armis API and authentication (advanced) |
| `ARMIS_REGION` | Authentication region override (advanced; corresponds to `--region` flag) |

**General:**

Expand Down
5 changes: 1 addition & 4 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,9 @@ func (c *Client) IsDebug() bool {
// request URL uses HTTPS (or localhost for testing). This prevents credential
// exposure over insecure channels.
//
// For JWT auth: sends raw JWT token (no "Bearer" prefix)
// For JWT auth: sends "Bearer <token>" per RFC 6750
// For Basic auth: sends "Basic <token>" per RFC 7617
//
// NOTE: The backend expects raw JWT tokens without the "Bearer" prefix.
// This is unconventional but matches the backend API contract.
//
// SECURITY NOTE: The localhost/127.0.0.1 exception is intentional for local
// development and testing environments where HTTPS certificates are not available.
// Production deployments must always use HTTPS.
Expand Down
117 changes: 97 additions & 20 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
Expand All @@ -18,7 +20,8 @@ type AuthConfig struct {
// JWT auth credentials
ClientID string
ClientSecret string //nolint:gosec // G117: This is a config field name, not a secret value
AuthEndpoint string // Full URL to the authentication service
BaseURL string // Moose API base URL (dev or prod)
Region string // Optional region override - bypasses auto-discovery if set

// Legacy Basic auth
Token string
Expand All @@ -33,21 +36,24 @@ type JWTCredentials struct {
Token string
TenantID string // Extracted from customer_id claim
ExpiresAt time.Time
Region string // Deployment region (e.g., "us1", "eu1", "au1")
}

// AuthProvider manages authentication tokens with automatic refresh.
// It supports both JWT authentication and legacy Basic authentication.
// For JWT auth, tokens are automatically refreshed when within 5 minutes of expiry.
type AuthProvider struct {
config AuthConfig
credentials *JWTCredentials
authClient *AuthClient
mu sync.RWMutex
isLegacy bool // true if using Basic auth (--token)
config AuthConfig
credentials *JWTCredentials
authClient *AuthClient
mu sync.RWMutex
isLegacy bool // true if using Basic auth (--token)
cachedRegion string // memoized region from disk cache (loaded once)
regionLoaded bool // true if cachedRegion has been loaded from disk
}

// NewAuthProvider creates an AuthProvider from configuration.
// If ClientID and ClientSecret are set, uses JWT auth with the specified endpoint.
// If ClientID and ClientSecret are set, uses JWT auth with the specified base URL.
// Otherwise falls back to legacy Basic auth with Token.
func NewAuthProvider(config AuthConfig) (*AuthProvider, error) {
p := &AuthProvider{
Expand All @@ -64,12 +70,12 @@ func NewAuthProvider(config AuthConfig) (*AuthProvider, error) {

// Determine auth mode: JWT credentials take priority
if config.ClientID != "" && config.ClientSecret != "" {
// JWT auth
// JWT auth via moose
p.isLegacy = false
if config.AuthEndpoint == "" {
return nil, fmt.Errorf("--auth-endpoint is required when using client credentials")
if config.BaseURL == "" {
return nil, fmt.Errorf("base URL is required for JWT authentication")
}
authClient, err := NewAuthClient(config.AuthEndpoint, config.Debug)
authClient, err := NewAuthClient(config.BaseURL, config.Debug)
if err != nil {
return nil, fmt.Errorf("failed to create auth client: %w", err)
}
Expand All @@ -86,14 +92,14 @@ func NewAuthProvider(config AuthConfig) (*AuthProvider, error) {
return nil, fmt.Errorf("tenant ID required: use --tenant-id flag or ARMIS_TENANT_ID environment variable")
}
} else {
return nil, fmt.Errorf("authentication required: use --token flag or ARMIS_API_TOKEN environment variable")
return nil, fmt.Errorf("authentication required: set ARMIS_CLIENT_ID and ARMIS_CLIENT_SECRET for JWT auth, or ARMIS_API_TOKEN for legacy auth")
}

return p, nil
}

// GetAuthorizationHeader returns the Authorization header value.
// For JWT auth: the raw JWT token (no "Bearer" prefix - backend expects raw JWT)
// For JWT auth: "Bearer <token>" per RFC 6750
// For Basic auth: "Basic <token>" per RFC 7617
func (p *AuthProvider) GetAuthorizationHeader(ctx context.Context) (string, error) {
if p.isLegacy {
Expand All @@ -108,8 +114,8 @@ func (p *AuthProvider) GetAuthorizationHeader(ctx context.Context) (string, erro

p.mu.RLock()
defer p.mu.RUnlock()
// Raw JWT token (no Bearer prefix) - backend expects raw JWT per API contract
return p.credentials.Token, nil
// Bearer token per RFC 6750
return "Bearer " + p.credentials.Token, nil
}

// GetTenantID returns the tenant ID for API requests.
Expand All @@ -129,6 +135,23 @@ func (p *AuthProvider) GetTenantID(ctx context.Context) (string, error) {
return p.credentials.TenantID, nil
}

// GetRegion returns the deployment region from the JWT token.
// For JWT auth: extracted from region claim (may be empty for older tokens)
// For Basic auth: returns empty string (no region available)
func (p *AuthProvider) GetRegion(ctx context.Context) (string, error) {
if p.isLegacy {
return "", nil // Legacy auth doesn't have region
}

if err := p.refreshIfNeeded(ctx); err != nil {
return "", fmt.Errorf("failed to refresh token: %w", err)
}

p.mu.RLock()
defer p.mu.RUnlock()
return p.credentials.Region, nil
}

// IsLegacy returns true if using legacy Basic auth.
func (p *AuthProvider) IsLegacy() bool {
return p.isLegacy
Expand Down Expand Up @@ -160,6 +183,16 @@ func (p *AuthProvider) GetRawToken(ctx context.Context) (string, error) {

// exchangeCredentials exchanges client credentials for a JWT token.
// Uses double-checked locking to prevent thundering herd of concurrent refreshes.
// Leverages region caching to avoid auto-discovery overhead on subsequent requests.
//
// Region selection priority:
// 1. --region flag (config.Region) - explicit override, bypasses cache and discovery
// 2. Cached region - from previous successful auth for this client_id
// 3. Auto-discovery - server tries regions until one succeeds
//
// Retry behavior: If auth fails with a cached region hint (not explicit --region),
// the cache is cleared and auth is retried without the hint. This handles stale
// cache gracefully without requiring user to re-run the command.
func (p *AuthProvider) exchangeCredentials(ctx context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
Expand All @@ -169,21 +202,62 @@ func (p *AuthProvider) exchangeCredentials(ctx context.Context) error {
return nil
}

token, err := p.authClient.Authenticate(ctx, p.config.ClientID, p.config.ClientSecret)
// Load cached region once per process (memoize to avoid repeated disk I/O)
if !p.regionLoaded {
if region, ok := loadCachedRegion(p.config.ClientID); ok {
p.cachedRegion = region
}
p.regionLoaded = true
}

// Determine region hint - explicit flag takes priority over cache
var regionHint *string
var usingCachedHint bool
if p.config.Region != "" {
// Explicit --region flag - don't retry on failure (user error)
regionHint = &p.config.Region
} else if p.cachedRegion != "" {
// Cached region - will retry without hint on failure
regionHint = &p.cachedRegion
usingCachedHint = true
}

result, err := p.authClient.Authenticate(ctx, p.config.ClientID, p.config.ClientSecret, regionHint)
if err != nil {
return err
// If auth failed with a cached region hint, retry only for region-specific rejections.
// Skip retry for: transport errors (not *AuthError), 401 (bad credentials).
// This avoids double requests on network failures and prevents wiping correct cache entries.
var authErr *AuthError
if usingCachedHint && errors.As(err, &authErr) && authErr.StatusCode != http.StatusUnauthorized {
clearCachedRegion()
p.cachedRegion = ""
// Retry without region hint - let server auto-discover
result, err = p.authClient.Authenticate(ctx, p.config.ClientID, p.config.ClientSecret, nil)
if err != nil {
return err
}
} else {
return err
}
}

// Cache the discovered region for future requests (skip if unchanged)
if result.Region != "" && result.Region != p.cachedRegion {
saveCachedRegion(p.config.ClientID, result.Region)
p.cachedRegion = result.Region
}

// Parse JWT to extract claims
claims, err := parseJWTClaims(token)
claims, err := parseJWTClaims(result.Token)
if err != nil {
return fmt.Errorf("failed to parse JWT: %w", err)
}

p.credentials = &JWTCredentials{
Token: token,
Token: result.Token,
TenantID: claims.CustomerID,
ExpiresAt: claims.ExpiresAt,
Region: claims.Region,
}

return nil
Expand All @@ -207,6 +281,7 @@ func (p *AuthProvider) refreshIfNeeded(ctx context.Context) error {
type jwtClaims struct {
CustomerID string // maps to tenant_id
ExpiresAt time.Time
Region string // deployment region (optional)
}

// parseJWTClaims extracts claims from a JWT without signature verification.
Expand All @@ -233,7 +308,8 @@ func parseJWTClaims(token string) (*jwtClaims, error) {

var data struct {
CustomerID string `json:"customer_id"`
Exp float64 `json:"exp"` // float64 to handle servers that return fractional timestamps
Exp float64 `json:"exp"` // float64 to handle servers that return fractional timestamps
Region string `json:"region"` // optional deployment region
}
if err := json.Unmarshal(payload, &data); err != nil {
return nil, fmt.Errorf("failed to parse JWT payload: %w", err)
Expand All @@ -252,5 +328,6 @@ func parseJWTClaims(token string) (*jwtClaims, error) {
return &jwtClaims{
CustomerID: data.CustomerID,
ExpiresAt: time.Unix(expSec, 0),
Region: data.Region, // may be empty for backward compatibility
}, nil
}
Loading
Loading