diff --git a/cli/azd/cmd/auth_login.go b/cli/azd/cmd/auth_login.go index 9c9ca9cb99c..fe26fccd379 100644 --- a/cli/azd/cmd/auth_login.go +++ b/cli/azd/cmd/auth_login.go @@ -33,6 +33,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools/github" "github.com/spf13/cobra" "github.com/spf13/pflag" + "go.opentelemetry.io/otel/attribute" ) // The parent of the login command. @@ -214,6 +215,10 @@ func newLoginCmd(parent string) *cobra.Command { To log in using a managed identity, pass --managed-identity, which will use the system assigned managed identity. To use a user assigned managed identity, pass --client-id in addition to --managed-identity with the client id of the user assigned managed identity you wish to use. + + When already logged in, azd automatically clears cached authentication data (such as stale tokens) + before re-authenticating. This ensures a clean login state and prevents issues with expired or + corrupted cached credentials. `), Annotations: map[string]string{ loginCmdParentAnnotation: parent, @@ -363,6 +368,21 @@ func (la *loginAction) Run(ctx context.Context) (*actions.ActionResult, error) { } } + // When already logged in (or when login state cannot be determined due to corrupted cache), + // clear cached auth data before re-authenticating. This prevents issues with stale MSAL + // tokens or corrupted credential cache files that can cause AADSTS700082 errors even after + // a successful login. Only skip cleanup when definitively not logged in. + if _, err := la.authManager.LogInDetails(ctx); !errors.Is(err, auth.ErrNoCurrentUser) { + if err := la.authManager.CleanAllAuthCache(); err != nil { + tracing.SetUsageAttributes(attribute.String("auth.cache_clear_failed", "auth")) + return nil, fmt.Errorf("clearing auth cache: %w", err) + } + if err := la.accountSubManager.ClearSubscriptions(ctx); err != nil { + tracing.SetUsageAttributes(attribute.String("auth.cache_clear_failed", "subscriptions")) + return nil, fmt.Errorf("clearing subscriptions cache: %w", err) + } + } + if err := la.login(ctx); err != nil { return nil, err } diff --git a/cli/azd/docs/authentication.md b/cli/azd/docs/authentication.md new file mode 100644 index 00000000000..eab7dcd150c --- /dev/null +++ b/cli/azd/docs/authentication.md @@ -0,0 +1,222 @@ +# Authentication + +This document covers the authentication methods supported by `azd auth login` and how `azd` manages +authentication state. + +## Authentication methods + +### Interactive browser login (default) + +The default method. Running `azd auth login` opens a browser window for you to sign in with your +Microsoft Entra ID (Azure AD) account. + +```bash +azd auth login +``` + +To target a specific tenant: + +```bash +azd auth login --tenant-id +``` + +To choose the local port used for the redirect URI during the browser flow: + +```bash +azd auth login --redirect-port 8080 +``` + +### Device code login + +Use device code flow when a browser is not available on the current machine (e.g. SSH sessions, +containers, Codespaces in a browser). + +```bash +azd auth login --use-device-code +``` + +### Service principal with client secret + +Authenticate as a service principal using a client secret. Both `--client-id` and `--tenant-id` are +required. + +```bash +azd auth login \ + --client-id \ + --tenant-id \ + --client-secret +``` + +If you pass `--client-secret` with an empty value, `azd` prompts you to enter the secret +interactively (useful to avoid leaking secrets in shell history). + +### Service principal with client certificate + +Authenticate using a PEM-encoded certificate file. + +```bash +azd auth login \ + --client-id \ + --tenant-id \ + --client-certificate /path/to/cert.pem +``` + +### Federated credentials (OIDC) + +Federated token providers allow authentication without secrets in CI/CD environments using +OpenID Connect (OIDC). + +#### GitHub Actions + +```bash +azd auth login \ + --client-id \ + --tenant-id \ + --federated-credential-provider github +``` + +The `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` environment variables must be +available (GitHub sets these automatically when `id-token: write` is granted in the workflow). + +#### Azure Pipelines + +```bash +azd auth login \ + --client-id \ + --tenant-id \ + --federated-credential-provider azure-pipelines +``` + +When using `azure-pipelines`, the following environment variables are read automatically if +`--client-id` or `--tenant-id` are not provided: + +| Variable | Description | +|---|---| +| `AZURESUBSCRIPTION_CLIENT_ID` | Client ID of the service connection | +| `AZURESUBSCRIPTION_TENANT_ID` | Tenant ID of the service connection | +| `AZURESUBSCRIPTION_SERVICE_CONNECTION_ID` | Service connection ID (required) | +| `SYSTEM_ACCESSTOKEN` | Pipeline system access token (must be mapped via `env`) | + +#### Generic OIDC + +For other OIDC-compatible providers: + +```bash +azd auth login \ + --client-id \ + --tenant-id \ + --federated-credential-provider oidc +``` + +### Managed identity + +Authenticate using a managed identity when running on an Azure compute resource (VMs, App Service, +Container Apps, etc.). + +```bash +# System-assigned managed identity +azd auth login --managed-identity + +# User-assigned managed identity +azd auth login --managed-identity --client-id +``` + +### Delegated authentication (Azure CLI) + +You can configure `azd` to delegate authentication to the Azure CLI (`az`) instead of managing +credentials itself. This is useful when `azd` does not yet support your preferred authentication +method. + +```bash +azd config set auth.useAzCliAuth true +``` + +When this is set, `azd auth login` detects the delegated mode and offers to switch back to +built-in authentication. To authenticate, use `az login` directly. + +### External authentication + +When `azd` is launched by a host tool (e.g. the VS Code extension), the host can provide +authentication by setting the `AZD_AUTH_ENDPOINT` and `AZD_AUTH_KEY` environment variables. In this +mode, `azd` proxies all token requests to the host process. + +For full details on the external authentication protocol, see +[External Authentication](external-authentication.md). + +## Checking login status + +To verify whether you are currently logged in without triggering a new login flow: + +```bash +azd auth login --check-status +``` + +This prints the current authentication status and exits. Use `--output json` for machine-readable +output that includes the token expiration time. + +## Logging out + +To sign out and remove cached authentication data: + +```bash +azd auth logout +``` + +This removes the current user from the MSAL cache, deletes stored service principal credentials, +and clears the subscriptions cache. + +## Automatic authentication state cleanup on re-login + +> **Added in:** [#7541](https://github.com/Azure/azure-dev/issues/7541) + +In rare cases, `azd` may report an expired or invalid token error (e.g. `AADSTS700082: The refresh +token has expired due to inactivity`) even immediately after a successful `azd auth login`. This +happens because stale data in the local MSAL token cache or credential files can interfere with +the new login session. + +To prevent this, `azd auth login` automatically detects when you are already logged in and clears +all locally cached authentication data before re-authenticating. This gives you a clean slate +without requiring any extra flags. If you are not currently logged in, `azd auth login` proceeds +directly with the normal login flow without clearing anything. + +### What is cleared on re-login + +| Item | Path | Description | +|---|---|---| +| MSAL token cache | `~/.azd/auth/msal/` | Cached access and refresh tokens from MSAL | +| Credential cache | `~/.azd/auth/` | Stored service principal secrets and certificates | +| Auth config | `~/.azd/auth.json` | Current user identity metadata | +| Claims file | `~/.azd/auth.claims` | Cached claims from previous login | +| Subscription cache | `~/.azd/subscriptions.cache` | Cached list of accessible subscriptions | + +After clearing, the directory structure is recreated and the normal login flow proceeds. This is +equivalent to manually deleting these files and then running `azd auth login`. + +### When automatic cleanup helps + +The automatic cleanup resolves situations where: + +- You see `AADSTS700082` or similar stale-token errors right after logging in successfully +- `azd` commands fail with authentication errors that persist across multiple `azd auth login` + attempts +- You are switching tenants or accounts and want to ensure a completely fresh authentication + state + +## How authentication state is stored + +`azd` stores authentication data under the user configuration directory (default `~/.azd/`, +overridable via `AZD_CONFIG_DIR`): + +```text +~/.azd/ +├── auth.json # Current user identity (home account ID, client/tenant IDs) +├── auth.claims # Cached claims for re-login +├── subscriptions.cache # Cached Azure subscriptions +└── auth/ + └── msal/ + ├── cache*.json # MSAL token cache (access/refresh tokens) + └── cred*.json # Service principal credential cache +``` + +On Windows, the MSAL cache is encrypted using `CryptProtectData` and stored as `.bin` files instead +of `.json`. On all platforms, auth files are ACL'd to be readable only by the current user. diff --git a/cli/azd/pkg/auth/manager.go b/cli/azd/pkg/auth/manager.go index f91566c8c3c..59be302404f 100644 --- a/cli/azd/pkg/auth/manager.go +++ b/cli/azd/pkg/auth/manager.go @@ -1093,6 +1093,42 @@ func (m *Manager) Logout(ctx context.Context) error { return nil } +// CleanAllAuthCache removes all cached authentication data, including MSAL token cache files, +// credential cache files, auth config, and claims. This provides a clean slate for re-authentication, +// which resolves stale token issues (e.g. AADSTS700082 expired refresh tokens). +func (m *Manager) CleanAllAuthCache() error { + cfgRoot, err := config.GetUserConfigDir() + if err != nil { + return fmt.Errorf("getting config dir: %w", err) + } + + // Remove the entire auth directory (contains msal/ cache and credential files) + authRoot := filepath.Join(cfgRoot, "auth") + if err := os.RemoveAll(authRoot); err != nil { + return fmt.Errorf("removing auth directory: %w", err) + } + + // Remove auth.json (current user config) + authCfgFile := filepath.Join(cfgRoot, authConfigFileName) + if err := os.Remove(authCfgFile); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing auth config: %w", err) + } + + // Remove claims file + claimsFile := filepath.Join(cfgRoot, "auth.claims") + if err := os.Remove(claimsFile); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing claims file: %w", err) + } + + // Recreate the auth/msal directory structure so subsequent login can proceed + cacheRoot := filepath.Join(authRoot, "msal") + if err := os.MkdirAll(cacheRoot, osutil.PermissionDirectoryOwnerOnly); err != nil { + return fmt.Errorf("recreating msal cache directory: %w", err) + } + + return nil +} + func (m *Manager) UseExternalAuth() bool { return m.externalAuthCfg.Endpoint != "" && m.externalAuthCfg.Key != "" } diff --git a/cli/azd/pkg/auth/manager_coverage_test.go b/cli/azd/pkg/auth/manager_coverage_test.go index ea7700ff8cd..7e90636fde2 100644 --- a/cli/azd/pkg/auth/manager_coverage_test.go +++ b/cli/azd/pkg/auth/manager_coverage_test.go @@ -7,6 +7,7 @@ import ( "context" "errors" "os" + "path/filepath" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -14,6 +15,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/AzureAD/microsoft-authentication-library-for-go/apps/public" "github.com/azure/azure-dev/cli/azd/pkg/cloud" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -581,6 +583,73 @@ func TestLogout_NotLoggedIn(t *testing.T) { require.NoError(t, err) } +// --- CleanAllAuthCache --- + +func TestCleanAllAuthCache(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("AZD_CONFIG_DIR", tempDir) + + // Create auth directory structure with files + authDir := filepath.Join(tempDir, "auth") + msalDir := filepath.Join(authDir, "msal") + require.NoError(t, os.MkdirAll(msalDir, osutil.PermissionDirectoryOwnerOnly)) + + // Create MSAL cache file + require.NoError(t, os.WriteFile( + filepath.Join(msalDir, "cache.json"), []byte(`{"tokens":"stale"}`), osutil.PermissionFileOwnerOnly)) + + // Create credential cache file + require.NoError(t, os.WriteFile( + filepath.Join(authDir, "credtenant.client.json"), []byte(`{"secret":"old"}`), osutil.PermissionFileOwnerOnly)) + + // Create auth.json + require.NoError(t, os.WriteFile( + filepath.Join(tempDir, "auth.json"), []byte(`{"auth.account.currentUser":{}}`), osutil.PermissionFileOwnerOnly)) + + // Create auth.claims + require.NoError(t, os.WriteFile( + filepath.Join(tempDir, "auth.claims"), []byte(`claims-data`), osutil.PermissionFileOwnerOnly)) + + m := &Manager{} + err := m.CleanAllAuthCache() + require.NoError(t, err) + + // auth.json should be removed + _, err = os.Stat(filepath.Join(tempDir, "auth.json")) + assert.True(t, os.IsNotExist(err), "auth.json should be deleted") + + // auth.claims should be removed + _, err = os.Stat(filepath.Join(tempDir, "auth.claims")) + assert.True(t, os.IsNotExist(err), "auth.claims should be deleted") + + // Old MSAL cache files should be gone + _, err = os.Stat(filepath.Join(msalDir, "cache.json")) + assert.True(t, os.IsNotExist(err), "MSAL cache should be deleted") + + // Old credential files should be gone + _, err = os.Stat(filepath.Join(authDir, "credtenant.client.json")) + assert.True(t, os.IsNotExist(err), "credential cache should be deleted") + + // auth/msal directory should be recreated (empty) + info, err := os.Stat(msalDir) + require.NoError(t, err, "msal directory should be recreated") + assert.True(t, info.IsDir()) +} + +func TestCleanAllAuthCache_NoExistingFiles(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("AZD_CONFIG_DIR", tempDir) + + m := &Manager{} + err := m.CleanAllAuthCache() + require.NoError(t, err, "should succeed even when no auth files exist") + + // auth/msal directory should still be created + info, err := os.Stat(filepath.Join(tempDir, "auth", "msal")) + require.NoError(t, err) + assert.True(t, info.IsDir()) +} + // --- EnsureLoggedInCredential --- func TestEnsureLoggedInCredential_Success(t *testing.T) { diff --git a/cli/azd/pkg/errorhandler/pipeline_test.go b/cli/azd/pkg/errorhandler/pipeline_test.go index 5a0a8179d14..6c242dd303c 100644 --- a/cli/azd/pkg/errorhandler/pipeline_test.go +++ b/cli/azd/pkg/errorhandler/pipeline_test.go @@ -714,3 +714,35 @@ func TestErrorSuggestionsYaml_LoadPipelineConfig(t *testing.T) { require.NotNil(t, pipeline) assert.NotEmpty(t, pipeline.rules, "pipeline must load rules from embedded YAML") } + +func TestErrorSuggestionsYaml_AADSTS700082_SpecificRule(t *testing.T) { + t.Parallel() + pipeline := NewErrorHandlerPipeline(nil) + + // AADSTS700082 should match the specific rule, not just the generic AADSTS one + result := pipeline.Process( + t.Context(), + errors.New("AADSTS700082: The refresh token has expired due to inactivity"), + ) + require.NotNil(t, result) + assert.Equal(t, "The refresh token has expired or been revoked.", result.Message) + assert.Contains(t, result.Suggestion, "azd auth login") + assert.Contains(t, result.Suggestion, "automatically clears stale cached tokens") + require.Len(t, result.Links, 1) + assert.Contains(t, result.Links[0].URL, "azd-auth-login") +} + +func TestErrorSuggestionsYaml_AADSTS_GenericRule(t *testing.T) { + t.Parallel() + pipeline := NewErrorHandlerPipeline(nil) + + // A generic AADSTS error should match the broad rule and mention auto-clearing + result := pipeline.Process( + t.Context(), + errors.New("AADSTS530084: some other auth error"), + ) + require.NotNil(t, result) + assert.Equal(t, "Authentication with Azure failed.", result.Message) + assert.Contains(t, result.Suggestion, "azd auth login") + assert.Contains(t, result.Suggestion, "automatically clears cached authentication data") +} diff --git a/cli/azd/resources/error_suggestions.yaml b/cli/azd/resources/error_suggestions.yaml index df571c9b8c9..02991ac6f44 100644 --- a/cli/azd/resources/error_suggestions.yaml +++ b/cli/azd/resources/error_suggestions.yaml @@ -604,10 +604,24 @@ rules: # Text Pattern Rules — Broad/generic patterns (least specific, must be last) # ============================================================================ + - patterns: + - "AADSTS700082" + message: "The refresh token has expired or been revoked." + suggestion: >- + Run 'azd auth login' to sign in again. + When already logged in, azd automatically clears stale cached tokens before + re-authenticating. + links: + - url: "https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-auth-login" + title: "azd auth login reference" + - patterns: - "AADSTS" message: "Authentication with Azure failed." - suggestion: "Run 'azd auth login' to sign in again." + suggestion: >- + Run 'azd auth login' to sign in again. + When already logged in, azd automatically clears cached authentication data before + re-authenticating to prevent stale token issues. links: - url: "https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-auth-login" title: "azd auth login reference"