Skip to content

Commit b3d8d70

Browse files
Fix LogInDetails() to check for external auth before falling through to local auth config (#8004)
* Initial plan * Fix LogInDetails() to handle external auth (UseExternalAuth) correctly Agent-Logs-Url: https://github.com/Azure/azure-dev/sessions/84c64f74-2291-4d35-9140-34e98182b0d3 Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> * Apply review feedback: use ClaimsForCurrentUser and validate non-empty account name Agent-Logs-Url: https://github.com/Azure/azure-dev/sessions/ea7b51fa-0517-4597-a29d-e9e2cd889eea Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> * Address wbreza review: doc comment + TODO for hardcoded EmailLoginType Agent-Logs-Url: https://github.com/Azure/azure-dev/sessions/0aeda872-c855-445c-bc4b-50f929838abe Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> * Use valid JWT in Test_CLI_Auth_ExternalAuth so claims parsing succeeds Agent-Logs-Url: https://github.com/Azure/azure-dev/sessions/02de36fe-29ca-4aad-a437-23e98a408678 Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com>
1 parent fa340cc commit b3d8d70

3 files changed

Lines changed: 98 additions & 2 deletions

File tree

cli/azd/pkg/auth/manager.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1399,8 +1399,30 @@ type LogInDetails struct {
13991399
// LogInDetails contains information about the currently logged in user.
14001400
// It provides details about the type of login (email-based or client ID-based)
14011401
// and the account identifier. When legacy authentication is used, it will
1402-
// return the account name from the az CLI.
1402+
// return the account name from the az CLI. When external authentication is
1403+
// configured, it will acquire a token from the external auth endpoint (an
1404+
// outbound HTTP call) and derive the account identifier from the token claims.
14031405
func (m *Manager) LogInDetails(ctx context.Context) (*LogInDetails, error) {
1406+
if m.UseExternalAuth() {
1407+
claims, err := m.ClaimsForCurrentUser(ctx, nil)
1408+
if err != nil {
1409+
return nil, fmt.Errorf("fetching claims for external auth: %w", err)
1410+
}
1411+
1412+
accountName := strings.TrimSpace(claims.DisplayUsername())
1413+
if accountName == "" {
1414+
return nil, fmt.Errorf("external auth token did not contain a usable account identifier: %w", ErrNoCurrentUser)
1415+
}
1416+
1417+
// TODO: External auth could theoretically serve service principal tokens, but we always
1418+
// report EmailLoginType here. CurrentPrincipalType() maps this to UserType, which matches
1419+
// the existing assumption that external auth represents a user identity.
1420+
return &LogInDetails{
1421+
LoginType: EmailLoginType,
1422+
Account: accountName,
1423+
}, nil
1424+
}
1425+
14041426
userConfig, err := m.userConfigManager.Load()
14051427
if err != nil {
14061428
return nil, fmt.Errorf("reading user config: %w", err)

cli/azd/pkg/auth/manager_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"fmt"
1111
"io"
1212
"net/http"
13+
"net/http/httptest"
1314
"os"
1415
"strings"
1516
"testing"
@@ -381,6 +382,63 @@ func TestLogInDetails(t *testing.T) {
381382
require.Error(t, err)
382383
require.ErrorIs(t, err, ErrNoCurrentUser)
383384
})
385+
386+
t.Run("external auth - returns email login type with upn from token", func(t *testing.T) {
387+
// Build a JWT token with a preferred_username claim
388+
token := buildTestJWT(t, map[string]any{
389+
"preferred_username": "user@contoso.com",
390+
"oid": "oid-abc",
391+
"tid": "tenant-xyz",
392+
})
393+
394+
// Set up a mock HTTP server that returns the token
395+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
396+
w.WriteHeader(http.StatusOK)
397+
_, _ = io.WriteString(w, `{"status":"success","token":"`+token+`","expiresOn":"2030-01-01T00:00:00Z"}`)
398+
}))
399+
defer srv.Close()
400+
401+
m := Manager{
402+
externalAuthCfg: ExternalAuthConfiguration{
403+
Endpoint: srv.URL,
404+
Key: "test-key",
405+
Transporter: srv.Client(),
406+
},
407+
cloud: cloud.AzurePublic(),
408+
}
409+
410+
details, err := m.LogInDetails(t.Context())
411+
require.NoError(t, err)
412+
require.Equal(t, EmailLoginType, details.LoginType)
413+
require.Equal(t, "user@contoso.com", details.Account)
414+
})
415+
416+
t.Run("external auth - error when token has no usable account identifier", func(t *testing.T) {
417+
// Build a JWT token with no username claims
418+
token := buildTestJWT(t, map[string]any{
419+
"oid": "oid-abc",
420+
"tid": "tenant-xyz",
421+
})
422+
423+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
424+
w.WriteHeader(http.StatusOK)
425+
_, _ = io.WriteString(w, `{"status":"success","token":"`+token+`","expiresOn":"2030-01-01T00:00:00Z"}`)
426+
}))
427+
defer srv.Close()
428+
429+
m := Manager{
430+
externalAuthCfg: ExternalAuthConfiguration{
431+
Endpoint: srv.URL,
432+
Key: "test-key",
433+
Transporter: srv.Client(),
434+
},
435+
cloud: cloud.AzurePublic(),
436+
}
437+
438+
_, err := m.LogInDetails(t.Context())
439+
require.Error(t, err)
440+
require.ErrorIs(t, err, ErrNoCurrentUser)
441+
})
384442
}
385443

386444
func newMemoryUserConfigManager() *memoryUserConfigManager {

cli/azd/test/functional/auth_test.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package cli_test
55

66
import (
7+
"encoding/base64"
78
"encoding/json"
89
"fmt"
910
"net/http"
@@ -18,6 +19,19 @@ import (
1819
"github.com/stretchr/testify/require"
1920
)
2021

22+
// buildFakeJWT constructs a minimal unsigned JWT with the provided claims.
23+
// It is sufficient for tests that only need azd to parse claims out of the
24+
// token; the signature is not verified.
25+
func buildFakeJWT(t *testing.T, claims map[string]any) string {
26+
t.Helper()
27+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
28+
body, err := json.Marshal(claims)
29+
require.NoError(t, err)
30+
payload := base64.RawURLEncoding.EncodeToString(body)
31+
sig := base64.RawURLEncoding.EncodeToString([]byte("fakesig"))
32+
return fmt.Sprintf("%s.%s.%s", header, payload, sig)
33+
}
34+
2135
func Test_CLI_Auth_ExternalAuth(t *testing.T) {
2236
// running this test in parallel is ok as it uses a t.TempDir()
2337
t.Parallel()
@@ -35,7 +49,9 @@ func Test_CLI_Auth_ExternalAuth(t *testing.T) {
3549
// what we handed back.
3650

3751
// nolint:gosec
38-
expectedToken := "THIS-IS-A-FAKE-TOKEN"
52+
expectedToken := buildFakeJWT(t, map[string]any{
53+
"preferred_username": "user@contoso.com",
54+
})
3955
expectedExpiresOn := time.Now().UTC().Add(time.Hour).Format(time.RFC3339)
4056

4157
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)