diff --git a/README.md b/README.md index 963b342..f9cc43d 100644 --- a/README.md +++ b/README.md @@ -904,7 +904,12 @@ 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 | |----------|-------------| @@ -912,14 +917,22 @@ pipelines: | `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:** @@ -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 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ad495df..0694b40 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "net/http" + "os" "strings" "sync" "time" @@ -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 @@ -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. @@ -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 @@ -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) @@ -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) } @@ -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) } @@ -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) @@ -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 diff --git a/internal/auth/browser.go b/internal/auth/browser.go new file mode 100644 index 0000000..eb810cd --- /dev/null +++ b/internal/auth/browser.go @@ -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 +} diff --git a/internal/auth/browser_test.go b/internal/auth/browser_test.go new file mode 100644 index 0000000..f73fc82 --- /dev/null +++ b/internal/auth/browser_test.go @@ -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) + } +} diff --git a/internal/auth/client.go b/internal/auth/client.go index ea9aa4e..6153cb0 100644 --- a/internal/auth/client.go +++ b/internal/auth/client.go @@ -22,6 +22,11 @@ const ( // ProductionBaseURL is the default Armis API endpoint (US region / primary). ProductionBaseURL = "https://moose.armis.com" + + // schemeHTTPS / schemeHTTP are URL scheme literals shared across the auth + // package's HTTPS-enforcement checks. + schemeHTTPS = "https" + schemeHTTP = "http" ) // RegionalBaseURL returns the Armis API base URL for the given region code. @@ -84,7 +89,7 @@ func NewAuthClient(baseURL string, debug bool) (*AuthClient, error) { // armis:ignore cwe:522 reason:this code IS the credential protection check (HTTPS enforcement for non-localhost) // armis:ignore cwe:918 reason:baseURL is operator-controlled (ARMIS_API_URL) or the hardcoded RegionalBaseURL allowlist, never attacker-reachable input; this block IS the SSRF guard (rejects non-HTTPS non-localhost hosts) - if parsedURL.Scheme != "https" { + if parsedURL.Scheme != schemeHTTPS { host := parsedURL.Hostname() if host != "localhost" && host != "127.0.0.1" { return nil, fmt.Errorf("HTTPS required for non-localhost URLs") diff --git a/internal/auth/device.go b/internal/auth/device.go new file mode 100644 index 0000000..8297ca2 --- /dev/null +++ b/internal/auth/device.go @@ -0,0 +1,385 @@ +// Package auth provides authentication for the Armis API. +// This file implements the OAuth2 Device Authorization Grant (RFC 8628) client +// used by `armis-cli auth login`. The server side is the Moose OAuth2 +// authorization server (PPSC-1033), mounted at the issuer root. +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/ArmisSecurity/armis-cli/internal/httpclient" +) + +const ( + // DefaultDeviceClientID is the public client_id armis-cli identifies as in + // the device flow. The CLI is a public client (no secret); security comes + // from the device_code and refresh-token rotation, not this identifier. + DefaultDeviceClientID = "armis-cli" + + // Grant types (RFC 8628 §3.4 / RFC 6749 §6). + grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" + grantTypeRefreshToken = "refresh_token" + + // deviceEndpointPath / tokenEndpointPath are root-mounted on the issuer per + // RFC 8628 / the backend router (api_controller/oauth2/router.py). + deviceEndpointPath = "/oauth2/device" + tokenEndpointPath = "/oauth2/token" // #nosec G101 -- URL path, not a credential + + // Polling guardrails so a misbehaving server cannot make us hammer it. + minPollInterval = 1 * time.Second + defaultPollInterval = 5 * time.Second + maxPollInterval = 60 * time.Second +) + +// DeviceAuthorization is the RFC 8628 §3.2 device authorization response. +type DeviceAuthorization struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// tokenResponse mirrors the backend TokenResponse (RFC 6749 §5.1). +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope,omitempty"` +} + +// oauthErrorResponse is the RFC 6749 §5.2 / RFC 8628 §3.5 error body. +type oauthErrorResponse struct { + ErrorCode string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` +} + +// OAuthError is a typed OAuth2 protocol error so callers can branch on the code +// (e.g. authorization_pending vs. expired_token). +type OAuthError struct { + Code string + Description string + StatusCode int +} + +func (e *OAuthError) Error() string { + if e.Description != "" { + return fmt.Sprintf("%s: %s", e.Code, e.Description) + } + return e.Code +} + +// OAuth2 error codes we branch on (RFC 8628 §3.5). +const ( + errAuthorizationPending = "authorization_pending" + errSlowDown = "slow_down" + errExpiredToken = "expired_token" + errAccessDenied = "access_denied" + errInvalidGrant = "invalid_grant" +) + +// DeviceClient talks to the OAuth2 device + token endpoints on the issuer. +type DeviceClient struct { + baseURL string + httpClient *http.Client + debug bool +} + +// NewDeviceClient creates a device-flow client for the given issuer base URL. +// HTTPS is enforced for non-localhost hosts and redirects are disabled, matching +// the hardening of the client-credentials AuthClient. +func NewDeviceClient(baseURL string, debug bool) (*DeviceClient, error) { + if baseURL == "" { + return nil, fmt.Errorf("API base URL is required for device authentication") + } + + parsedURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid base URL: %w", err) + } + + // armis:ignore cwe:918 reason:baseURL is operator-controlled (ARMIS_API_URL) or the hardcoded RegionalBaseURL allowlist; this block IS the SSRF guard (rejects non-HTTPS non-localhost hosts) + if parsedURL.Scheme != schemeHTTPS { + host := parsedURL.Hostname() + if host != "localhost" && host != "127.0.0.1" { + return nil, fmt.Errorf("HTTPS required for non-localhost URLs") + } + } + + return &DeviceClient{ + baseURL: strings.TrimSuffix(baseURL, "/"), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + // Honor OS proxy config (WinINET/PAC), matching AuthClient. + Transport: httpclient.ProxyAwareTransport(), + // Never follow redirects: the token endpoint carries the device_code + // and refresh_token, which must not be replayed to a redirect target. + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + debug: debug, + }, nil +} + +// RequestDeviceCode performs the RFC 8628 §3.1 device authorization request. +// tenantID identifies which Armis tenant to authenticate against and is required +// by the authorization server. +func (c *DeviceClient) RequestDeviceCode(ctx context.Context, clientID, tenantID, scope string) (*DeviceAuthorization, error) { + if tenantID == "" { + return nil, fmt.Errorf("tenant_id is required to start the device authorization") + } + form := url.Values{} + form.Set("client_id", clientID) + form.Set("tenant_id", tenantID) + if scope != "" { + form.Set("scope", scope) + } + + body, status, err := c.postForm(ctx, deviceEndpointPath, form) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, c.parseError(body, status) + } + + var da DeviceAuthorization + if err := json.Unmarshal(body, &da); err != nil { + return nil, fmt.Errorf("failed to parse device authorization response: %w", err) + } + if da.DeviceCode == "" || da.UserCode == "" { + return nil, fmt.Errorf("device authorization response missing required fields") + } + return &da, nil +} + +// PollToken polls the token endpoint until the user approves, the device code +// expires, or the request is denied (RFC 8628 §3.4/§3.5). It honors the server's +// interval and backs off on slow_down. The provided context bounds the total +// wait (callers should set a deadline ~ the device code's expires_in). +func (c *DeviceClient) PollToken(ctx context.Context, deviceCode, clientID string, intervalSeconds int) (*StoredToken, error) { + interval := time.Duration(intervalSeconds) * time.Second + if interval < minPollInterval { + interval = defaultPollInterval + } + if interval > maxPollInterval { + interval = maxPollInterval + } + + for { + // Wait first: the spec requires waiting `interval` between polls, and the + // authorization is never approved instantly anyway. + select { + case <-ctx.Done(): + return nil, fmt.Errorf("timed out waiting for authorization: %w", ctx.Err()) + case <-time.After(interval): + } + + tok, err := c.exchangeDeviceCode(ctx, deviceCode, clientID) + if err == nil { + return tok, nil + } + + var oerr *OAuthError + if !asOAuthError(err, &oerr) { + return nil, err // transport / parse error — give up + } + switch oerr.Code { + case errAuthorizationPending: + continue + case errSlowDown: + interval += 5 * time.Second + if interval > maxPollInterval { + interval = maxPollInterval + } + continue + case errExpiredToken: + return nil, fmt.Errorf("the login request expired before it was approved; run 'armis-cli auth login' again") + case errAccessDenied: + return nil, fmt.Errorf("the login request was denied") + default: + return nil, oerr + } + } +} + +// exchangeDeviceCode does a single device_code token exchange. +func (c *DeviceClient) exchangeDeviceCode(ctx context.Context, deviceCode, clientID string) (*StoredToken, error) { + form := url.Values{} + form.Set("grant_type", grantTypeDeviceCode) + form.Set("device_code", deviceCode) + form.Set("client_id", clientID) + return c.tokenRequest(ctx, form, clientID) +} + +// Refresh exchanges a refresh token for a fresh access/refresh token pair +// (RFC 6749 §6). The backend rotates the refresh token, so the returned +// StoredToken carries a new RefreshToken that callers must persist. +func (c *DeviceClient) Refresh(ctx context.Context, refreshToken, clientID string) (*StoredToken, error) { + form := url.Values{} + form.Set("grant_type", grantTypeRefreshToken) + form.Set("refresh_token", refreshToken) + if clientID != "" { + form.Set("client_id", clientID) + } + return c.tokenRequest(ctx, form, clientID) +} + +// tokenRequest posts to the token endpoint and converts a success response into +// a StoredToken, deriving identity fields from the access-token claims. +func (c *DeviceClient) tokenRequest(ctx context.Context, form url.Values, clientID string) (*StoredToken, error) { + body, status, err := c.postForm(ctx, tokenEndpointPath, form) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, c.parseError(body, status) + } + + var tr tokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return nil, fmt.Errorf("failed to parse token response: %w", err) + } + if tr.AccessToken == "" { + return nil, fmt.Errorf("token response missing access_token") + } + + claims, err := parseAccessTokenClaims(tr.AccessToken) + if err != nil { + return nil, fmt.Errorf("failed to parse access token: %w", err) + } + + // Prefer the server-provided expires_in; fall back to the token's exp claim. + expiresAt := claims.ExpiresAt + if tr.ExpiresIn > 0 { + expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second) + } + + return &StoredToken{ + AccessToken: tr.AccessToken, + RefreshToken: tr.RefreshToken, + ExpiresAt: expiresAt, + TenantID: claims.TenantID, + Subject: claims.Subject, + Role: claims.Role, + Issuer: claims.Issuer, + Region: claims.Region, + ClientID: clientID, + }, nil +} + +// postForm issues a form-encoded POST and returns the body and status code. +func (c *DeviceClient) postForm(ctx context.Context, path string, form url.Values) ([]byte, int, error) { + endpoint := c.baseURL + path + // armis:ignore cwe:918 reason:baseURL validated by NewDeviceClient (HTTPS enforced for non-localhost); path is a hardcoded constant + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) //nolint:gosec // endpoint built from validated config, not user input + if err != nil { + return nil, 0, fmt.Errorf("request failed: %w", annotateTransportError(err)) + } + defer resp.Body.Close() //nolint:errcheck // response body read-only + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize)) + if err != nil { + return nil, 0, fmt.Errorf("failed to read response: %w", err) + } + return body, resp.StatusCode, nil +} + +// parseError converts an OAuth2 error body into a typed *OAuthError. When the +// body is not the expected JSON shape it falls back to a status-based message. +func (c *DeviceClient) parseError(body []byte, status int) error { + var oe oauthErrorResponse + if err := json.Unmarshal(body, &oe); err == nil && oe.ErrorCode != "" { + return &OAuthError{Code: oe.ErrorCode, Description: oe.ErrorDescription, StatusCode: status} + } + return &OAuthError{Code: "server_error", Description: fmt.Sprintf("unexpected response (status %d)", status), StatusCode: status} +} + +// asOAuthError is errors.As specialized for *OAuthError. +func asOAuthError(err error, target **OAuthError) bool { + for err != nil { + if oe, ok := err.(*OAuthError); ok { //nolint:errorlint // direct type assert is intentional here + *target = oe + return true + } + type unwrapper interface{ Unwrap() error } + u, ok := err.(unwrapper) + if !ok { + return false + } + err = u.Unwrap() + } + return false +} + +// accessTokenClaims are the Moose RS256 access-token claims (token_issuer.py). +// This is distinct from jwtClaims (client-credentials path), which reads the +// VIPR customer_id claim; the device-flow token uses tenant_id/sub/role. +type accessTokenClaims struct { + TenantID string + Subject string + Role string + Issuer string + Region string + ExpiresAt time.Time +} + +// parseAccessTokenClaims decodes (without verifying) the JWT payload. Signature +// verification is delegated to the backend, which validates every API request; +// the CLI only needs the claims for local display and refresh scheduling. +// +// armis:ignore cwe:287 reason:JWT signature verification delegated to server; CLI only extracts claims for caching/display +// armis:ignore cwe:327 reason:no cryptographic operations; base64-decodes JWT payload for claim extraction only +func parseAccessTokenClaims(token string) (*accessTokenClaims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode JWT payload: %w", err) + } + + var data struct { + TenantID string `json:"tenant_id"` + Sub string `json:"sub"` + Role string `json:"role"` + Iss string `json:"iss"` + Region string `json:"region"` + Exp float64 `json:"exp"` // float64 tolerates fractional timestamps + } + if err := json.Unmarshal(payload, &data); err != nil { + return nil, fmt.Errorf("failed to parse JWT payload: %w", err) + } + + var expiresAt time.Time + if data.Exp > 0 { + expiresAt = time.Unix(int64(data.Exp), 0) + } + return &accessTokenClaims{ + TenantID: data.TenantID, + Subject: data.Sub, + Role: data.Role, + Issuer: data.Iss, + Region: data.Region, + ExpiresAt: expiresAt, + }, nil +} diff --git a/internal/auth/device_test.go b/internal/auth/device_test.go new file mode 100644 index 0000000..54cfb54 --- /dev/null +++ b/internal/auth/device_test.go @@ -0,0 +1,211 @@ +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +// makeDeviceJWT builds an unsigned JWT carrying the device-flow access-token +// claims (tenant_id/sub/role), distinct from the client-credentials customer_id. +func makeDeviceJWT(tenantID, sub, role string, exp int64) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + claims := map[string]any{ + "tenant_id": tenantID, + "sub": sub, + "role": role, + "iss": "https://moose.armis.com", + "exp": exp, + } + cj, _ := json.Marshal(claims) + payload := base64.RawURLEncoding.EncodeToString(cj) + sig := base64.RawURLEncoding.EncodeToString([]byte("sig")) + return header + "." + payload + "." + sig +} + +func TestRequestDeviceCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth2/device" { + w.WriteHeader(http.StatusNotFound) + return + } + _ = r.ParseForm() //nolint:gosec // G120: test server; request body is a tiny fixed form + if r.Form.Get("client_id") != "armis-cli" { + t.Errorf("client_id = %q", r.Form.Get("client_id")) + } + if r.Form.Get("tenant_id") != "tenant-1" { + t.Errorf("tenant_id = %q, want tenant-1", r.Form.Get("tenant_id")) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_code": "dev-code", + "user_code": "WDJB-MJHT", + "verification_uri": "https://moose.armis.com/oauth2/device/verify", + "verification_uri_complete": "https://moose.armis.com/oauth2/device/verify?user_code=WDJB-MJHT", + "expires_in": 900, + "interval": 5, + }) + })) + defer srv.Close() + + c, err := NewDeviceClient(srv.URL, false) + if err != nil { + t.Fatal(err) + } + da, err := c.RequestDeviceCode(context.Background(), "armis-cli", "tenant-1", "") + if err != nil { + t.Fatalf("RequestDeviceCode: %v", err) + } + if da.DeviceCode != "dev-code" || da.UserCode != "WDJB-MJHT" || da.Interval != 5 { + t.Errorf("unexpected device authorization: %+v", da) + } +} + +func TestRequestDeviceCodeRequiresTenant(t *testing.T) { + c, err := NewDeviceClient("https://moose.armis.com", false) + if err != nil { + t.Fatal(err) + } + if _, err := c.RequestDeviceCode(context.Background(), "armis-cli", "", ""); err == nil { + t.Fatal("expected error when tenant_id is empty") + } +} + +func TestPollTokenPendingThenSuccess(t *testing.T) { + var mu sync.Mutex + calls := 0 + jwt := makeDeviceJWT("tenant-1", "user@example.com", "admin", time.Now().Add(time.Hour).Unix()) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + n := calls + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if n < 2 { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": errAuthorizationPending}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": jwt, + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "refresh-1", + }) + })) + defer srv.Close() + + c, err := NewDeviceClient(srv.URL, false) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // interval=0 is clamped to the default; override to keep the test fast by + // using a 1s minimum via a tiny interval value. + tok, err := c.PollToken(ctx, "dev-code", "armis-cli", 1) + if err != nil { + t.Fatalf("PollToken: %v", err) + } + if tok.AccessToken != jwt || tok.RefreshToken != "refresh-1" { + t.Errorf("unexpected tokens: %+v", tok) + } + if tok.TenantID != "tenant-1" || tok.Subject != "user@example.com" || tok.Role != "admin" { + t.Errorf("claims not parsed: %+v", tok) + } +} + +func TestPollTokenAccessDenied(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": errAccessDenied}) + })) + defer srv.Close() + + c, _ := NewDeviceClient(srv.URL, false) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := c.PollToken(ctx, "dev-code", "armis-cli", 1) + if err == nil { + t.Fatal("expected denial error") + } +} + +func TestPollTokenExpired(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": errExpiredToken}) + })) + defer srv.Close() + + c, _ := NewDeviceClient(srv.URL, false) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := c.PollToken(ctx, "dev-code", "armis-cli", 1) + if err == nil { + t.Fatal("expected expiry error") + } +} + +func TestRefresh(t *testing.T) { + jwt := makeDeviceJWT("tenant-9", "svc", "viewer", time.Now().Add(time.Hour).Unix()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() //nolint:gosec // G120: test server; request body is a tiny fixed form + if r.Form.Get("grant_type") != grantTypeRefreshToken { + t.Errorf("grant_type = %q", r.Form.Get("grant_type")) + } + if r.Form.Get("refresh_token") != "old-refresh" { + t.Errorf("refresh_token = %q", r.Form.Get("refresh_token")) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": jwt, + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh", + }) + })) + defer srv.Close() + + c, _ := NewDeviceClient(srv.URL, false) + tok, err := c.Refresh(context.Background(), "old-refresh", "armis-cli") + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if tok.RefreshToken != "new-refresh" || tok.TenantID != "tenant-9" { + t.Errorf("unexpected refreshed token: %+v", tok) + } +} + +func TestNewDeviceClientRejectsHTTP(t *testing.T) { + if _, err := NewDeviceClient("http://moose.armis.com", false); err == nil { + t.Fatal("expected HTTPS enforcement error") + } + // localhost http is allowed (tests / dev). + if _, err := NewDeviceClient("http://localhost:8080", false); err != nil { + t.Errorf("localhost http should be allowed: %v", err) + } +} + +func TestParseAccessTokenClaims(t *testing.T) { + jwt := makeDeviceJWT("t", "s", "r", 1700000000) + claims, err := parseAccessTokenClaims(jwt) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.TenantID != "t" || claims.Subject != "s" || claims.Role != "r" { + t.Errorf("unexpected claims: %+v", claims) + } + if claims.ExpiresAt.Unix() != 1700000000 { + t.Errorf("exp = %v", claims.ExpiresAt.Unix()) + } +} diff --git a/internal/auth/oauth_provider_test.go b/internal/auth/oauth_provider_test.go new file mode 100644 index 0000000..aaccec0 --- /dev/null +++ b/internal/auth/oauth_provider_test.go @@ -0,0 +1,115 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// newOAuthTestProvider wires an SSO-mode provider against a mock token endpoint +// and an in-memory token store. +func newOAuthTestProvider(t *testing.T, srvURL string, stored *StoredToken) (*AuthProvider, *TokenStore) { + t.Helper() + store := &TokenStore{dir: t.TempDir()} + if err := store.Save(srvURL, stored); err != nil { + t.Fatalf("seed store: %v", err) + } + dc, err := NewDeviceClient(srvURL, false) + if err != nil { + t.Fatalf("device client: %v", err) + } + p, err := NewProviderFromStored(store, dc, srvURL, stored) + if err != nil { + t.Fatalf("provider: %v", err) + } + return p, store +} + +func TestOAuthProviderUsesStoredTokenWithoutRefresh(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("token endpoint should not be called when token is fresh") + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + stored := sampleToken() + stored.AccessToken = makeDeviceJWT("tenant-1", "u", "admin", time.Now().Add(time.Hour).Unix()) + stored.ExpiresAt = time.Now().Add(time.Hour) + + p, _ := newOAuthTestProvider(t, srv.URL, stored) + + hdr, err := p.GetAuthorizationHeader(context.Background()) + if err != nil { + t.Fatalf("GetAuthorizationHeader: %v", err) + } + if hdr != "Bearer "+stored.AccessToken { + t.Errorf("unexpected header: %q", hdr) + } + if p.AuthMethod() != AuthMethodSSO { + t.Errorf("AuthMethod = %q, want sso", p.AuthMethod()) + } +} + +func TestOAuthProviderRefreshesNearExpiry(t *testing.T) { + newJWT := makeDeviceJWT("tenant-1", "u", "admin", time.Now().Add(time.Hour).Unix()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() //nolint:gosec // G120: test server; request body is a tiny fixed form + if r.Form.Get("grant_type") != grantTypeRefreshToken { + t.Errorf("expected refresh grant, got %q", r.Form.Get("grant_type")) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": newJWT, + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rotated-refresh", + }) + })) + defer srv.Close() + + stored := sampleToken() + stored.AccessToken = "stale" + stored.ExpiresAt = time.Now().Add(1 * time.Minute) // within the 5-min window + + p, store := newOAuthTestProvider(t, srv.URL, stored) + + hdr, err := p.GetAuthorizationHeader(context.Background()) + if err != nil { + t.Fatalf("GetAuthorizationHeader: %v", err) + } + if hdr != "Bearer "+newJWT { + t.Errorf("expected refreshed token in header, got %q", hdr) + } + + // The rotated refresh token must be persisted for the next process. + reloaded, _ := store.Load(srv.URL) + if reloaded == nil || reloaded.RefreshToken != "rotated-refresh" { + t.Errorf("rotated refresh token not persisted: %+v", reloaded) + } +} + +func TestOAuthProviderRefreshFailureSurfacesReloginHint(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": errInvalidGrant}) + })) + defer srv.Close() + + stored := sampleToken() + stored.ExpiresAt = time.Now().Add(1 * time.Minute) + + p, _ := newOAuthTestProvider(t, srv.URL, stored) + + _, err := p.GetTenantID(context.Background()) + if err == nil { + t.Fatal("expected error on failed refresh") + } + if want := "auth login"; !strings.Contains(err.Error(), want) { + t.Errorf("error %q should mention %q", err.Error(), want) + } +} diff --git a/internal/auth/tokenstore.go b/internal/auth/tokenstore.go new file mode 100644 index 0000000..81b7011 --- /dev/null +++ b/internal/auth/tokenstore.go @@ -0,0 +1,269 @@ +// Package auth provides authentication for the Armis API. +// This file persists OAuth2 (device-flow) tokens so they survive across +// invocations and can be shared with other Armis tools (the MCP plugins). +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// CROSS-PROCESS CONTRACT — DO NOT CHANGE THE PATH OR JSON SCHEMA CASUALLY. +// +// The token file and its JSON schema are a wire contract shared with other +// Armis developer tools (the armis-appsec / armis-knowledge MCP plugins, per +// epic PPSC-1032). Those tools read and write the SAME file so a single +// `armis-cli auth login` keeps every tool authenticated. +// +// A plain file (not the OS keychain) is the deliberate choice: the MCP plugins +// are Python, and the refresh-token rotation + reuse-detection on the backend +// requires a SINGLE source of truth (a divergent second store would replay a +// rotated token and get the whole token family revoked). This matches the +// AWS/gcloud/kubectl/gh model of a per-user credential file. +// +// At rest: Unix writes 0600 in a 0700 ~/.armis. On Windows those mode bits are a +// no-op (NTFS uses ACLs; os.Stat reports 0666); confidentiality there relies on +// the %USERPROFILE% ACL ~/.armis inherits, same as the tools above. +// +// FILE SHAPE — a JSON array of per-environment entries, so one dev machine can +// hold tokens for several Armis environments at once (prod, dev, a local stack): +// +// [ +// {"env": "https://moose.armis.com", "token": { ...StoredToken... }}, +// {"env": "http://localhost:8001", "token": { ...StoredToken... }} +// ] +// +// `env` is the API base URL the token was obtained from (the lookup key). +// Python equivalent of the path: Path.home() / ".armis" / ".sessions". +// --------------------------------------------------------------------------- +const ( + // tokenStoreDirName is the per-user Armis config directory (~/.armis). + tokenStoreDirName = ".armis" + // tokenStoreFileName is the token file within that directory. + tokenStoreFileName = ".sessions" // #nosec G101 -- filename, not a credential + // tokenSchemaVersion versions the StoredToken JSON so future changes can be + // detected by older readers rather than mis-parsed. + tokenSchemaVersion = 1 + // maxTokenFileSize bounds reads to guard against a corrupted or maliciously + // large file exhausting memory. Generous to accommodate many environments. + maxTokenFileSize = 1 << 20 // 1MB +) + +// StoredToken is the persisted result of a device-flow login. Its JSON shape is +// the cross-process contract described above; add fields rather than renaming. +type StoredToken struct { + SchemaVersion int `json:"schema_version"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt time.Time `json:"expires_at"` + TenantID string `json:"tenant_id"` + Subject string `json:"subject"` + Role string `json:"role"` + Issuer string `json:"issuer,omitempty"` + Region string `json:"region,omitempty"` + ClientID string `json:"client_id,omitempty"` +} + +// tokenEntry is one environment's token within the file array. +type tokenEntry struct { + Env string `json:"env"` + Token *StoredToken `json:"token"` +} + +// TokenStore persists OAuth tokens to a 0600 file under ~/.armis, keyed by the +// environment (API base URL) each token belongs to. +type TokenStore struct { + // dir overrides the directory holding the token file (tests only). Empty + // means ~/.armis. + dir string +} + +// NewTokenStore returns a TokenStore backed by the per-user ~/.armis directory. +func NewTokenStore() *TokenStore { + return &TokenStore{} +} + +// normalizeEnv canonicalizes an environment key so trivially different spellings +// (a trailing slash, surrounding whitespace) resolve to the same entry. +func normalizeEnv(env string) string { + return strings.TrimRight(strings.TrimSpace(env), "/") +} + +// Save inserts or replaces the token for the given environment. +func (s *TokenStore) Save(env string, tok *StoredToken) error { + if tok == nil { + return errors.New("nil token") + } + if env == "" { + return errors.New("env is required to store a token") + } + tok.SchemaVersion = tokenSchemaVersion + env = normalizeEnv(env) + + entries, err := s.read() + if err != nil { + return err + } + + replaced := false + for i := range entries { + if normalizeEnv(entries[i].Env) == env { + entries[i].Token = tok + replaced = true + break + } + } + if !replaced { + entries = append(entries, tokenEntry{Env: env, Token: tok}) + } + return s.write(entries) +} + +// Load returns the stored token for the given environment, or (nil, nil) when +// none is present. A corrupted or oversized file is treated as "no token" so a +// bad file never breaks credential resolution — callers fall through to env vars. +func (s *TokenStore) Load(env string) (*StoredToken, error) { + env = normalizeEnv(env) + entries, err := s.read() + if err != nil { + return nil, nil //nolint:nilerr // unreadable/corrupted file treated as absent + } + for i := range entries { + if normalizeEnv(entries[i].Env) == env { + tok := entries[i].Token + if tok == nil || (tok.AccessToken == "" && tok.RefreshToken == "") { + return nil, nil + } + return tok, nil + } + } + return nil, nil +} + +// Clear removes the token for the given environment. It is idempotent. When the +// last entry is removed the file itself is deleted. +func (s *TokenStore) Clear(env string) error { + env = normalizeEnv(env) + entries, err := s.read() + if err != nil { + return nil //nolint:nilerr // nothing usable to clear + } + kept := entries[:0] + for _, e := range entries { + if normalizeEnv(e.Env) != env { + kept = append(kept, e) + } + } + if len(kept) == 0 { + return s.remove() + } + return s.write(kept) +} + +// ClearAll removes every stored token by deleting the file. +func (s *TokenStore) ClearAll() error { + return s.remove() +} + +// Environments lists the environments that currently have a stored token. +func (s *TokenStore) Environments() []string { + entries, err := s.read() + if err != nil { + return nil + } + envs := make([]string, 0, len(entries)) + for _, e := range entries { + envs = append(envs, e.Env) + } + return envs +} + +// Path returns the resolved token-file path (for diagnostics / logout output). +func (s *TokenStore) Path() string { + path, _ := s.filePath() + return path +} + +// read loads and parses the token file. A missing file yields an empty slice; +// a corrupted/oversized file yields an error so callers can decide how to react +// (Load/Clear treat it as absent rather than failing the CLI). +func (s *TokenStore) read() ([]tokenEntry, error) { + path, err := s.filePath() + if err != nil { + return nil, err + } + // armis:ignore cwe:367 reason:stat-then-read race is benign; worst case reads a stale token, no security impact + info, statErr := os.Stat(path) + if statErr != nil { + if os.IsNotExist(statErr) { + return nil, nil + } + return nil, statErr + } + if info.Size() > maxTokenFileSize { + return nil, fmt.Errorf("token file %s exceeds %d bytes", path, maxTokenFileSize) + } + data, err := os.ReadFile(path) //nolint:gosec // path derived from os.UserHomeDir + hardcoded segments + if err != nil { + return nil, err + } + if len(data) == 0 { + return nil, nil + } + var entries []tokenEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("token file is not valid JSON: %w", err) + } + return entries, nil +} + +// write persists the entries to the 0600 file, creating ~/.armis (0700) if needed. +func (s *TokenStore) write(entries []tokenEntry) error { + path, err := s.filePath() + if err != nil { + return err + } + data, err := json.MarshalIndent(entries, "", " ") //nolint:gosec // G117: persisting the token blob to its file IS the purpose of this store + if err != nil { + return fmt.Errorf("failed to marshal tokens: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("failed to create token directory: %w", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { //nolint:gosec // path derived from os.UserHomeDir + hardcoded segments + return fmt.Errorf("failed to write token file: %w", err) + } + return nil +} + +func (s *TokenStore) remove() error { + path, err := s.filePath() + if err != nil { + return nil //nolint:nilerr // nothing to remove if no path resolves + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// filePath resolves the token file path: