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
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -904,22 +904,35 @@ pipelines:

## Environment Variables

**JWT Authentication (Recommended):**
Pick the authentication method that matches the environment:

- **CI/CD and other non-interactive environments** — use client credentials (`ARMIS_CLIENT_ID` / `ARMIS_CLIENT_SECRET`). They authenticate without a browser, which is what automated pipelines need.
- **Developer machines and other interactive environments** — use SSO (`ARMIS_DEFAULT_AUTH_METHOD=SSO`). The CLI signs in through your company's identity provider in the browser, so no long-lived secret has to be stored on the machine.

**Client Credentials (recommended for CI/CD):**

| Variable | Description |
|----------|-------------|
| `ARMIS_CLIENT_ID` | Client ID for JWT authentication (from VIPR external API screen) |
| `ARMIS_CLIENT_SECRET` | Client secret for JWT authentication |
| `ARMIS_REGION` | Armis cloud region (equivalent to `--region` flag) |

When using JWT authentication, the tenant ID is automatically extracted from the token.
When using client credentials, the tenant ID is automatically extracted from the token.

**SSO (recommended for interactive use):**

| Variable | Description |
|----------|-------------|
| `ARMIS_DEFAULT_AUTH_METHOD` | Set to `SSO` to sign in through your company's configured identity provider when no other credentials are present (requires `ARMIS_TENANT_ID` or `--tenant-id`) |

You can also sign in explicitly at any time with `armis-cli auth login`; setting `ARMIS_DEFAULT_AUTH_METHOD=SSO` just triggers that sign-in automatically on the first command that needs credentials.

**Basic Authentication (Legacy):**

| Variable | Description |
|----------|-------------|
| `ARMIS_API_TOKEN` | API token for Basic authentication |
| `ARMIS_TENANT_ID` | Tenant identifier (required only with Basic auth) |
| `ARMIS_TENANT_ID` | Tenant identifier (required with Basic auth or SSO) |

**General:**

Expand All @@ -942,6 +955,7 @@ When using JWT authentication, the tenant ID is automatically extracted from the
- Use JWT authentication (client ID/secret) for production — it supports automatic token refresh and does not require a separate tenant ID
- Rotate credentials periodically
- Credentials are never logged or exposed in output
- SSO session tokens (`armis-cli auth login`) are stored per-user in `~/.armis/.sessions` — owner-only (`0600`) on macOS/Linux, protected by the user-profile ACL on Windows
- **Secure Transport**: All API communication uses HTTPS
- **Automatic Cleanup**: Temporary files are cleaned up after use
- **CI Detection**: Progress bars automatically disabled in CI environments
Expand Down
187 changes: 184 additions & 3 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -40,8 +41,14 @@ type JWTCredentials struct {
}

// 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.
// It supports three modes:
// - SSO / device flow (OAuth2): tokens obtained via `armis-cli auth login`,
// persisted in the token store, refreshed via the refresh-token grant.
// - JWT client credentials: client_id/client_secret exchanged for a JWT.
// - Legacy Basic auth: a static --token.
//
// For JWT and SSO auth, tokens are automatically refreshed when within 5 minutes
// of expiry.
type AuthProvider struct {
config AuthConfig
credentials *JWTCredentials
Expand All @@ -50,6 +57,83 @@ type AuthProvider struct {
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

// SSO / device-flow mode (mutually exclusive with isLegacy and JWT mode).
isOAuth bool
stored *StoredToken
deviceClient *DeviceClient
tokenStore *TokenStore
env string // environment key (API base URL) the token is stored under
}

// AuthMethod identifies which credential path the provider is using, for display
// by `armis-cli auth whoami`.
type AuthMethod string

const (
// AuthMethodSSO is the browser-based device-flow (OAuth2) login.
AuthMethodSSO AuthMethod = "sso"
// AuthMethodClientCredentials is the client_id/client_secret JWT exchange.
AuthMethodClientCredentials AuthMethod = "client-credentials"
// AuthMethodBasic is the legacy static-token Basic auth.
AuthMethodBasic AuthMethod = "basic"
)

// NewProviderFromStored builds an AuthProvider backed by an existing device-flow
// token for the given environment (API base URL). The provider transparently
// refreshes the access token via the refresh token when it nears expiry,
// persisting the rotated pair back to the store under that same environment.
func NewProviderFromStored(store *TokenStore, deviceClient *DeviceClient, env string, stored *StoredToken) (*AuthProvider, error) {
if store == nil || deviceClient == nil || stored == nil {
return nil, fmt.Errorf("token store, device client, and stored token are required")
}
if env == "" {
return nil, fmt.Errorf("env is required")
}
return &AuthProvider{
isOAuth: true,
stored: stored,
deviceClient: deviceClient,
tokenStore: store,
env: env,
}, nil
}

// AuthMethod returns the credential path in use.
func (p *AuthProvider) AuthMethod() AuthMethod {
switch {
case p.isOAuth:
return AuthMethodSSO
case p.isLegacy:
return AuthMethodBasic
default:
return AuthMethodClientCredentials
}
}

// Identity returns the subject (user/service identifier) for the current
// session. It is populated for SSO auth and empty otherwise.
func (p *AuthProvider) Identity() string {
p.mu.RLock()
defer p.mu.RUnlock()
if p.isOAuth && p.stored != nil {
return p.stored.Subject
}
return ""
}

// Expiry returns the access-token expiry for SSO/JWT auth, or the zero time when
// not applicable (Basic auth).
func (p *AuthProvider) Expiry() time.Time {
p.mu.RLock()
defer p.mu.RUnlock()
if p.isOAuth && p.stored != nil {
return p.stored.ExpiresAt
}
if p.credentials != nil {
return p.credentials.ExpiresAt
}
return time.Time{}
}

// NewAuthProvider creates an AuthProvider from configuration.
Expand Down Expand Up @@ -92,7 +176,8 @@ 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: set ARMIS_CLIENT_ID / ARMIS_CLIENT_SECRET (or use --client-id / --client-secret) for JWT auth, or ARMIS_API_TOKEN (--token) for legacy auth")
return nil, fmt.Errorf("authentication required: set ARMIS_CLIENT_ID / ARMIS_CLIENT_SECRET " +
"(or use --client-id / --client-secret) for JWT auth, or ARMIS_API_TOKEN (--token) for legacy auth")
}

return p, nil
Expand All @@ -107,6 +192,15 @@ func (p *AuthProvider) GetAuthorizationHeader(ctx context.Context) (string, erro
return "Basic " + p.config.Token, nil
}

if p.isOAuth {
if err := p.refreshOAuthIfNeeded(ctx); err != nil {
return "", err
}
p.mu.RLock()
defer p.mu.RUnlock()
return "Bearer " + p.stored.AccessToken, nil
}

// Refresh JWT if needed
if err := p.refreshIfNeeded(ctx); err != nil {
return "", fmt.Errorf("failed to refresh token: %w", err)
Expand All @@ -126,6 +220,15 @@ func (p *AuthProvider) GetTenantID(ctx context.Context) (string, error) {
return p.config.TenantID, nil
}

if p.isOAuth {
if err := p.refreshOAuthIfNeeded(ctx); err != nil {
return "", err
}
p.mu.RLock()
defer p.mu.RUnlock()
return p.stored.TenantID, nil
}

if err := p.refreshIfNeeded(ctx); err != nil {
return "", fmt.Errorf("failed to refresh token: %w", err)
}
Expand All @@ -143,6 +246,15 @@ func (p *AuthProvider) GetRegion(ctx context.Context) (string, error) {
return "", nil // Legacy auth doesn't have region
}

if p.isOAuth {
if err := p.refreshOAuthIfNeeded(ctx); err != nil {
return "", err
}
p.mu.RLock()
defer p.mu.RUnlock()
return p.stored.Region, nil
}

if err := p.refreshIfNeeded(ctx); err != nil {
return "", fmt.Errorf("failed to refresh token: %w", err)
}
Expand Down Expand Up @@ -171,6 +283,16 @@ func (p *AuthProvider) GetRawToken(ctx context.Context) (string, error) {
return p.config.Token, nil
}

if p.isOAuth {
if err := p.refreshOAuthIfNeeded(ctx); err != nil {
return "", err
}
p.mu.RLock()
defer p.mu.RUnlock()
// armis:ignore cwe:522 reason:returning token to caller is the API contract; token is used for authenticated API calls
return p.stored.AccessToken, nil
}

// Refresh JWT if needed
if err := p.refreshIfNeeded(ctx); err != nil {
return "", fmt.Errorf("failed to refresh token: %w", err)
Expand Down Expand Up @@ -300,6 +422,65 @@ func (p *AuthProvider) refreshIfNeeded(ctx context.Context) error {
return p.exchangeCredentials(ctx)
}

// refreshOAuthIfNeeded refreshes the device-flow access token via the refresh
// token when it is within 5 minutes of expiry, persisting the rotated pair back
// to the token store. Uses double-checked locking to avoid concurrent refreshes.
func (p *AuthProvider) refreshOAuthIfNeeded(ctx context.Context) error {
p.mu.RLock()
needsRefresh := p.stored == nil ||
time.Until(p.stored.ExpiresAt) < 5*time.Minute
p.mu.RUnlock()
if !needsRefresh {
return nil
}

p.mu.Lock()
defer p.mu.Unlock()
// Double-check: another goroutine may have refreshed while we waited.
if p.stored != nil && time.Until(p.stored.ExpiresAt) >= 5*time.Minute {
return nil
}
if p.stored == nil || p.stored.RefreshToken == "" {
return fmt.Errorf("your session has expired; run 'armis-cli auth login' to sign in again")
}

refreshed, err := p.deviceClient.Refresh(ctx, p.stored.RefreshToken, p.stored.ClientID)
if err != nil {
var oerr *OAuthError
if asOAuthError(err, &oerr) && (oerr.Code == errInvalidGrant || oerr.Code == errExpiredToken) {
return fmt.Errorf("your session has expired; run 'armis-cli auth login' to sign in again")
}
return fmt.Errorf("failed to refresh session: %w", err)
}

// Carry forward identity fields the refresh response may not echo.
if refreshed.TenantID == "" {
refreshed.TenantID = p.stored.TenantID
}
if refreshed.Subject == "" {
refreshed.Subject = p.stored.Subject
}
if refreshed.Role == "" {
refreshed.Role = p.stored.Role
}
if refreshed.Region == "" {
refreshed.Region = p.stored.Region
}
if refreshed.ClientID == "" {
refreshed.ClientID = p.stored.ClientID
}

p.stored = refreshed
if err := p.tokenStore.Save(p.env, refreshed); err != nil {
// Non-fatal: the in-memory token is valid for this process even if we
// could not persist it. A later invocation will refresh again.
if p.config.Debug {
fmt.Fprintf(os.Stderr, "[DEBUG] failed to persist refreshed token: %v\n", err)
}
}
return nil
}

// jwtClaims represents the relevant claims from a JWT.
type jwtClaims struct {
CustomerID string // maps to tenant_id
Expand Down
64 changes: 64 additions & 0 deletions internal/auth/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Package auth provides authentication for the Armis API.
// This file opens the system browser for the device-flow verification page.
package auth

import (
"fmt"
"net/url"
"os/exec"
"runtime"
)

// browserOpener is overridable so the device-login flow can be exercised
// without spawning a real browser. See SetBrowserOpener.
var browserOpener = openBrowserCmd

// SetBrowserOpener replaces the function used to launch the browser and returns
// a function that restores the previous opener. It is intended for tests (which
// must not spawn a real browser); production code uses the default opener.
func SetBrowserOpener(fn func(string) error) (restore func()) {
prev := browserOpener
browserOpener = fn
return func() { browserOpener = prev }
}

// OpenBrowser attempts to open the given URL in the user's default browser.
// It returns an error when no opener is available (headless server, SSH, locked
// down terminal); callers fall back to printing the URL and user_code.
//
// Only http(s) URLs are accepted, so a malformed verification URI cannot be
// turned into the execution of an arbitrary local handler.
func OpenBrowser(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if parsed.Scheme != schemeHTTP && parsed.Scheme != schemeHTTPS {
return fmt.Errorf("refusing to open non-http(s) URL")
}
return browserOpener(rawURL)
}

// openBrowserCmd launches the platform-specific browser opener.
//
// armis:ignore cwe:78 reason:URL is validated as http(s) by OpenBrowser and passed as a single argv element (no shell), not interpolated into a command string
func openBrowserCmd(rawURL string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
// #nosec G204 -- rawURL is validated as http(s) by OpenBrowser and passed as a separate argv element (no shell)
cmd = exec.Command("open", rawURL)
case "windows":
// #nosec G204 -- rawURL is validated as http(s) by OpenBrowser and passed as a separate argv element (no shell)
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
default: // linux, *bsd, etc.
// #nosec G204 -- rawURL is validated as http(s) by OpenBrowser and passed as a separate argv element (no shell)
cmd = exec.Command("xdg-open", rawURL)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to open browser: %w", err)
}
// Reap the child so it does not become a zombie; the browser detaches itself.
go func() { _ = cmd.Wait() }() //nolint:errcheck // fire-and-forget
return nil
}
31 changes: 31 additions & 0 deletions internal/auth/browser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package auth

import "testing"

func TestOpenBrowserRejectsNonHTTP(t *testing.T) {
restore := SetBrowserOpener(func(string) error {
t.Error("opener should not be called for a rejected scheme")
return nil
})
defer restore()

for _, bad := range []string{"file:///etc/passwd", "javascript:alert(1)", "ftp://host/x", "not a url"} {
if err := OpenBrowser(bad); err == nil {
t.Errorf("OpenBrowser(%q) = nil, want error", bad)
}
}
}

func TestOpenBrowserAllowsHTTPS(t *testing.T) {
var got string
restore := SetBrowserOpener(func(u string) error { got = u; return nil })
defer restore()

const url = "https://moose.armis.com/oauth2/device/verify?user_code=ABCD-EFGH"
if err := OpenBrowser(url); err != nil {
t.Fatalf("OpenBrowser: %v", err)
}
if got != url {
t.Errorf("opener got %q, want %q", got, url)
}
}
Loading
Loading