diff --git a/cli/azd/pkg/auth/manager.go b/cli/azd/pkg/auth/manager.go index f91566c8c3c..98cc05afb36 100644 --- a/cli/azd/pkg/auth/manager.go +++ b/cli/azd/pkg/auth/manager.go @@ -1402,6 +1402,9 @@ type LogInDetails struct { // return the account name from the az CLI. When external authentication is // configured, it will acquire a token from the external auth endpoint (an // outbound HTTP call) and derive the account identifier from the token claims. +// When running in Azure Cloud Shell and no azd-managed user is logged in, +// it derives the account from the ambient Cloud Shell credential and reports +// an authenticated user. func (m *Manager) LogInDetails(ctx context.Context) (*LogInDetails, error) { if m.UseExternalAuth() { claims, err := m.ClaimsForCurrentUser(ctx, nil) @@ -1462,7 +1465,17 @@ func (m *Manager) LogInDetails(ctx context.Context) (*LogInDetails, error) { currentUser, err := readUserProperties(cfg) if err != nil { - return nil, ErrNoCurrentUser + // In Cloud Shell azd uses the ambient credential, so report that user + // rather than treating the session as unauthenticated. Only fall back + // when there is genuinely no logged-in user; other errors (e.g. corrupted + // stored user properties) should surface so they aren't silently hidden. + if errors.Is(err, ErrNoCurrentUser) { + if runcontext.IsRunningInCloudShell() { + return m.cloudShellLogInDetails(ctx) + } + return nil, ErrNoCurrentUser + } + return nil, fmt.Errorf("reading current user properties: %w", err) } if currentUser.HomeAccountID != nil { @@ -1488,6 +1501,21 @@ func (m *Manager) LogInDetails(ctx context.Context) (*LogInDetails, error) { return nil, ErrNoCurrentUser } +// cloudShellLogInDetails reports the Cloud Shell user, derived from the ambient +// credential. The session is always a valid user, so an empty account (no +// username claim) is not an error. +func (m *Manager) cloudShellLogInDetails(ctx context.Context) (*LogInDetails, error) { + claims, err := m.ClaimsForCurrentUser(ctx, nil) + if err != nil { + return nil, fmt.Errorf("fetching claims for Cloud Shell user: %w", err) + } + + return &LogInDetails{ + LoginType: EmailLoginType, + Account: strings.TrimSpace(claims.DisplayUsername()), + }, nil +} + type AuthSource string const ( diff --git a/cli/azd/pkg/auth/manager_test.go b/cli/azd/pkg/auth/manager_test.go index d29ca303d65..fdf0d7e5611 100644 --- a/cli/azd/pkg/auth/manager_test.go +++ b/cli/azd/pkg/auth/manager_test.go @@ -413,6 +413,95 @@ func TestLogInDetails(t *testing.T) { require.Equal(t, "user@contoso.com", details.Account) }) + t.Run("cloud shell - returns user login type from token claims", func(t *testing.T) { + t.Setenv(runcontext.AzdInCloudShellEnvVar, "1") + + // Build an access token with a username claim and mock the Cloud Shell + // token endpoint to return it. + token := buildTestJWT(t, map[string]any{ + "unique_name": "user@contoso.com", + "oid": "oid-abc", + "tid": "tenant-xyz", + }) + + mockContext := mocks.NewMockContext(t.Context()) + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.URL.String() == "http://localhost:50342/oauth2/token" + }).Respond(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString( + fmt.Sprintf(`{"access_token":"%s","expires_on":"4070908800"}`, token))), + }) + + m := Manager{ + configManager: newMemoryConfigManager(), + userConfigManager: newMemoryUserConfigManager(), + httpClient: mockContext.HttpClient, + cloud: cloud.AzurePublic(), + } + + details, err := m.LogInDetails(t.Context()) + require.NoError(t, err) + require.Equal(t, EmailLoginType, details.LoginType) + require.Equal(t, "user@contoso.com", details.Account) + }) + + t.Run("cloud shell - authenticated even when token has no username claim", func(t *testing.T) { + t.Setenv(runcontext.AzdInCloudShellEnvVar, "1") + + // A Cloud Shell session is always a valid authenticated user, even if + // the token does not expose a username claim. + token := buildTestJWT(t, map[string]any{ + "oid": "oid-abc", + "tid": "tenant-xyz", + }) + + mockContext := mocks.NewMockContext(t.Context()) + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.URL.String() == "http://localhost:50342/oauth2/token" + }).Respond(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString( + fmt.Sprintf(`{"access_token":"%s","expires_on":"4070908800"}`, token))), + }) + + m := Manager{ + configManager: newMemoryConfigManager(), + userConfigManager: newMemoryUserConfigManager(), + httpClient: mockContext.HttpClient, + cloud: cloud.AzurePublic(), + } + + details, err := m.LogInDetails(t.Context()) + require.NoError(t, err) + require.Equal(t, EmailLoginType, details.LoginType) + require.Empty(t, details.Account) + }) + + t.Run("cloud shell - corrupted user properties surface error instead of fallback", func(t *testing.T) { + t.Setenv(runcontext.AzdInCloudShellEnvVar, "1") + + // A stored currentUser value that cannot be unmarshalled into userProperties + // represents real config corruption. It must surface as an error rather than + // being silently masked by the Cloud Shell fallback. + userCfg := config.NewEmptyConfig() + require.NoError(t, userCfg.Set(currentUserKey, "not-an-object")) + + userCfgMgr := newMemoryUserConfigManager() + require.NoError(t, userCfgMgr.Save(userCfg)) + + m := Manager{ + configManager: newMemoryConfigManager(), + userConfigManager: userCfgMgr, + cloud: cloud.AzurePublic(), + } + + _, err := m.LogInDetails(t.Context()) + require.Error(t, err) + require.NotErrorIs(t, err, ErrNoCurrentUser) + require.ErrorContains(t, err, "reading current user properties") + }) + t.Run("external auth - error when token has no usable account identifier", func(t *testing.T) { // Build a JWT token with no username claims token := buildTestJWT(t, map[string]any{