diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index d680e6f8fd0..ca6bd62892b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -115,12 +115,12 @@ func newInitCommand(rootFlags rootFlagsDefinition) *cobra.Command { return fmt.Errorf("failed to ground into a project context: %w", err) } - credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: azureContext.Scope.TenantId, - AdditionallyAllowedTenants: []string{"*"}, + credential, err := azure.NewCredential(ctx, azure.CredentialOptions{ + TenantID: azureContext.Scope.TenantId, + SubscriptionID: azureContext.Scope.SubscriptionId, }) if err != nil { - return fmt.Errorf("failed to create azure credential: %w", err) + return err } console := input.NewConsole( @@ -316,12 +316,12 @@ func ensureEnvironment(ctx context.Context, flags *initFlags, azdClient *azdext. return nil, fmt.Errorf("failed to get tenant ID: %w", err) } - credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: tenantResponse.TenantId, - AdditionallyAllowedTenants: []string{"*"}, + credential, err := azure.NewCredential(ctx, azure.CredentialOptions{ + TenantID: tenantResponse.TenantId, + SubscriptionID: foundryProject.SubscriptionId, }) if err != nil { - return nil, fmt.Errorf("failed to create Azure credential: %w", err) + return nil, err } // Create Cognitive Services Projects client diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/credential.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/credential.go new file mode 100644 index 00000000000..fc2f89815d7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/credential.go @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "context" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +// CredentialScope defines the Azure resource scope for token validation. +type CredentialScope string + +const ( + // ScopeAIFoundry is the scope for Azure AI Foundry APIs. + ScopeAIFoundry CredentialScope = "https://ai.azure.com/.default" + // ScopeARM is the scope for Azure Resource Manager APIs. + ScopeARM CredentialScope = "https://management.azure.com/.default" +) + +// CredentialOptions configures credential creation and validation. +type CredentialOptions struct { + // TenantID is the Azure AD tenant to authenticate against. + TenantID string + // SubscriptionID is used for error messages to help users identify the context. + SubscriptionID string + // Scope is the Azure resource scope to validate the credential against. + // If empty, defaults to ScopeARM. + Scope CredentialScope +} + +// NewCredential creates an AzureDeveloperCLICredential and validates it can obtain a token. +// This catches multi-tenant authentication issues early with a helpful error message. +func NewCredential(ctx context.Context, options CredentialOptions) (*azidentity.AzureDeveloperCLICredential, error) { + cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: options.TenantID, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return nil, fmt.Errorf("failed to create Azure credential: %w", err) + } + + scope := options.Scope + if scope == "" { + scope = ScopeARM + } + + // Validate the credential by attempting to get a token. + // The token is cached by the SDK, so subsequent calls reuse it. + _, err = cred.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{string(scope)}, + }) + if err != nil { + return nil, &AuthError{ + SubscriptionID: options.SubscriptionID, + TenantID: options.TenantID, + Cause: err, + } + } + + return cred, nil +} + +// AuthError represents an authentication failure with context for helpful error messages. +type AuthError struct { + SubscriptionID string + TenantID string + Cause error +} + +func (e *AuthError) Error() string { + return fmt.Sprintf( + "failed to authenticate for subscription '%s' in tenant '%s'.\n"+ + "Suggestion: if you recently gained access to this subscription, re-run `azd auth login`. Otherwise, visit this subscription in Azure Portal, then run `azd auth login`", + e.SubscriptionID, + e.TenantID) +} + +func (e *AuthError) Unwrap() error { + return e.Cause +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/parser.go b/cli/azd/extensions/azure.ai.agents/internal/project/parser.go index 4fc1c2ba9b5..a1488facb47 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/parser.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/parser.go @@ -5,6 +5,7 @@ package project import ( "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" "bytes" "context" "encoding/json" @@ -129,12 +130,12 @@ func (p *FoundryParser) SetIdentity(ctx context.Context, args *azdext.ProjectEve return fmt.Errorf("failed to get tenant ID: %w", err) } - cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: tenantResponse.TenantId, - AdditionallyAllowedTenants: []string{"*"}, + cred, err := azure.NewCredential(ctx, azure.CredentialOptions{ + TenantID: tenantResponse.TenantId, + SubscriptionID: subscriptionID, }) if err != nil { - return fmt.Errorf("failed to create Azure credential: %w", err) + return err } // Get Microsoft Foundry Project's managed identity @@ -375,12 +376,12 @@ func (p *FoundryParser) CoboPostDeploy(ctx context.Context, args *azdext.Project return fmt.Errorf("failed to get tenant ID: %w", err) } - cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: tenantResponse.TenantId, - AdditionallyAllowedTenants: []string{"*"}, + cred, err := azure.NewCredential(ctx, azure.CredentialOptions{ + TenantID: tenantResponse.TenantId, + SubscriptionID: projectSubscriptionID, }) if err != nil { - return fmt.Errorf("failed to create Azure credential: %w", err) + return err } // Get Microsoft Foundry region using SDK diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 05d49738db1..6af9480f692 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -97,13 +97,14 @@ func (p *AgentServiceTargetProvider) Initialize(ctx context.Context, serviceConf } p.tenantId = tenantResponse.TenantId - // Create Azure credential - cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - TenantID: p.tenantId, - AdditionallyAllowedTenants: []string{"*"}, + // Create and validate Azure credential + cred, err := azure.NewCredential(ctx, azure.CredentialOptions{ + TenantID: p.tenantId, + SubscriptionID: subscriptionId, + Scope: azure.ScopeAIFoundry, }) if err != nil { - return fmt.Errorf("failed to create Azure credential: %w", err) + return err } p.credential = cred