Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
27 changes: 26 additions & 1 deletion cli/azd/pkg/auth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -1462,7 +1462,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
Comment thread
JeffreyCA marked this conversation as resolved.
// 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) {
Comment thread
JeffreyCA marked this conversation as resolved.
if runcontext.IsRunningInCloudShell() {
return m.cloudShellLogInDetails(ctx)
}
return nil, ErrNoCurrentUser
}
return nil, fmt.Errorf("reading current user properties: %w", err)
}

if currentUser.HomeAccountID != nil {
Expand All @@ -1488,6 +1498,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 (
Expand Down
88 changes: 88 additions & 0 deletions cli/azd/pkg/auth/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,94 @@ 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)
Comment thread
JeffreyCA marked this conversation as resolved.
})

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{
Expand Down
Loading