Skip to content
Merged
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
3 changes: 3 additions & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,9 @@ overrides:
- filename: pkg/azdext/scope_detector.go
words:
- fakeazure
- filename: "{pkg/azapi/permissions.go,pkg/infra/provisioning/bicep/bicep_provider.go}"
words:
- ABAC
- filename: extensions/azure.ai.models/internal/cmd/custom_create.go
words:
- Qwen
Expand Down
44 changes: 34 additions & 10 deletions cli/azd/pkg/auth/azd_credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,48 @@ import (
)

type azdCredential struct {
client publicClient
account *public.Account
cloud *cloud.Cloud
client publicClient
account *public.Account
cloud *cloud.Cloud
tenantID string
}

func newAzdCredential(client publicClient, account *public.Account, cloud *cloud.Cloud) *azdCredential {
// newAzdCredential creates a credential that acquires tokens via MSAL's public client.
// tenantID, when non-empty, is forwarded to AcquireTokenSilent so MSAL issues tokens
// for that specific tenant instead of defaulting to the account's home tenant.
func newAzdCredential(
client publicClient, account *public.Account, cloud *cloud.Cloud, tenantID string,
) *azdCredential {
return &azdCredential{
client: client,
account: account,
cloud: cloud,
client: client,
account: account,
cloud: cloud,
tenantID: tenantID,
}
}

func (c *azdCredential) GetToken(ctx context.Context, options policy.TokenRequestOptions) (azcore.AccessToken, error) {
res, err := c.client.AcquireTokenSilent(ctx,
options.Scopes,
silentOpts := []public.AcquireSilentOption{
public.WithSilentAccount(*c.account),
public.WithClaims(options.Claims))
public.WithClaims(options.Claims),
}

// Forward the tenant ID so MSAL acquires a token from the correct tenant
// authority. The credential's tenantID is set when the credential is created
// for a specific tenant (e.g. resource tenant for B2B guests). The caller
// can override via options.TenantID (e.g. during CAE challenges).
// Without this, MSAL defaults to the account's home tenant, which causes
// cross-tenant calls (e.g. Graph /me for B2B guests) to return identity
// information for the home tenant instead of the resource tenant.
tenantID := c.tenantID
if options.TenantID != "" {
tenantID = options.TenantID
}
if tenantID != "" {
silentOpts = append(silentOpts, public.WithTenantID(tenantID))
}

res, err := c.client.AcquireTokenSilent(ctx, options.Scopes, silentOpts...)

if err != nil {
if authFailed, ok := errors.AsType[*AuthFailedError](err); ok {
Expand Down
103 changes: 103 additions & 0 deletions cli/azd/pkg/auth/azd_credential_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package auth

import (
"context"
"testing"
"time"

"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/AzureAD/microsoft-authentication-library-for-go/apps/public"
"github.com/azure/azure-dev/cli/azd/pkg/cloud"
"github.com/stretchr/testify/require"
)

// spyPublicClient records the options passed to AcquireTokenSilent so tests can
// verify that GetToken forwards TenantID correctly.
type spyPublicClient struct {
mockPublicClient
silentOptions []public.AcquireSilentOption
}

func (s *spyPublicClient) AcquireTokenSilent(
ctx context.Context, scopes []string, options ...public.AcquireSilentOption,
) (public.AuthResult, error) {
s.silentOptions = options
return public.AuthResult{
AccessToken: "test-token",
ExpiresOn: time.Now().Add(time.Hour),
Account: public.Account{
HomeAccountID: "test.id",
},
}, nil
}

func TestAzdCredential_GetToken_ForwardsTenantID(t *testing.T) {
tests := []struct {
name string
credTID string // tenant stored in credential
optsTID string // tenant from TokenRequestOptions
// expectTenantOption is true when WithTenantID should be in the options
expectTenantOption bool
// expectedTenantID is the tenant ID value that should be forwarded.
// When optsTID is set it overrides credTID.
expectedTenantID string
}{
{
name: "CredentialTenantUsed",
credTID: "resource-tenant-id",
optsTID: "",
expectTenantOption: true,
expectedTenantID: "resource-tenant-id",
},
{
name: "OptionsTenantOverrides",
credTID: "resource-tenant-id",
optsTID: "override-tenant-id",
expectTenantOption: true,
expectedTenantID: "override-tenant-id",
},
{
name: "NoTenant",
credTID: "",
optsTID: "",
expectTenantOption: false,
expectedTenantID: "",
},
}
Comment thread
vhvb1989 marked this conversation as resolved.

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
spy := &spyPublicClient{}
account := &public.Account{HomeAccountID: "test.id"}
cred := newAzdCredential(spy, account, cloud.AzurePublic(), tt.credTID)

_, err := cred.GetToken(t.Context(), policy.TokenRequestOptions{
Scopes: []string{"https://graph.microsoft.com/.default"},
TenantID: tt.optsTID,
})
require.NoError(t, err)

if tt.expectTenantOption {
require.Len(t, spy.silentOptions, 3,
"expected WithSilentAccount + WithClaims + WithTenantID")
} else {
require.Len(t, spy.silentOptions, 2,
"expected WithSilentAccount + WithClaims only")
}

// Verify the resolved tenant ID matches expectations.
// The credential resolves: optsTID if non-empty, else credTID.
// MSAL's WithTenantID option is opaque, so we verify the credential's
// tenant resolution logic directly (same package gives access to internals).
resolvedTenant := cred.tenantID
if tt.optsTID != "" {
resolvedTenant = tt.optsTID
}
require.Equal(t, tt.expectedTenantID, resolvedTenant,
"resolved tenant ID should match expected value")
})
}
}
9 changes: 5 additions & 4 deletions cli/azd/pkg/auth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ func (m *Manager) CredentialForCurrentUser(
for i, account := range accounts {
if account.HomeAccountID == *currentUser.HomeAccountID {
if options.TenantID == "" {
return newAzdCredential(m.publicClient, &accounts[i], m.cloud), nil
return newAzdCredential(m.publicClient, &accounts[i], m.cloud, "" /* tenantID */), nil
} else {
newAuthority := m.cloud.Configuration.ActiveDirectoryAuthorityHost + options.TenantID

Expand All @@ -342,7 +342,8 @@ func (m *Manager) CredentialForCurrentUser(
}

return newAzdCredential(
&msalPublicClientAdapter{client: &clientWithNewTenant}, &accounts[i], m.cloud), nil
&msalPublicClientAdapter{client: &clientWithNewTenant},
&accounts[i], m.cloud, options.TenantID), nil
}
}
}
Expand Down Expand Up @@ -741,7 +742,7 @@ func (m *Manager) LoginInteractive(
_ = os.Remove(claimsFile)
}

return newAzdCredential(m.publicClient, &res.Account, m.cloud), nil
return newAzdCredential(m.publicClient, &res.Account, m.cloud, "" /* tenantID */), nil
}

// LoginWithBrokerAccount logs in an account provided by the system authentication broker via OneAuth.
Expand Down Expand Up @@ -851,7 +852,7 @@ func (m *Manager) LoginWithDeviceCode(
_ = os.Remove(claimsFile)
}

return newAzdCredential(m.publicClient, &res.Account, m.cloud), nil
return newAzdCredential(m.publicClient, &res.Account, m.cloud, "" /* tenantID */), nil

}

Expand Down
Loading
Loading