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: 20 additions & 0 deletions cli/azd/cmd/auth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
222 changes: 222 additions & 0 deletions cli/azd/docs/authentication.md
Original file line number Diff line number Diff line change
@@ -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 <tenant-id-or-domain>
```

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 <app-id> \
--tenant-id <tenant-id> \
--client-secret <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 <app-id> \
--tenant-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 <app-id> \
--tenant-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 <app-id> \
--tenant-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 <app-id> \
--tenant-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 <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.
36 changes: 36 additions & 0 deletions cli/azd/pkg/auth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != ""
}
Expand Down
69 changes: 69 additions & 0 deletions cli/azd/pkg/auth/manager_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (
"context"
"errors"
"os"
"path/filepath"
"testing"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"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"
)
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading