cli/azd/pkg/auth/manager.go (884 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package auth import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "log" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/AzureAD/microsoft-authentication-library-for-go/apps/public" "github.com/azure/azure-dev/cli/azd/internal/runcontext" "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" "github.com/azure/azure-dev/cli/azd/pkg/cloud" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/github" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/oneauth" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/cli/browser" ) // TODO(azure/azure-dev#710): Right now, we re-use the App Id of the `az` CLI, until we have our own. // // nolint:lll // https://github.com/Azure/azure-cli/blob/azure-cli-2.41.0/src/azure-cli-core/azure/cli/core/auth/identity.py#L23 const azdClientID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" // currentUserKey is the key we use in config for the storing identity information of the currently logged in user. const currentUserKey = "auth.account.currentUser" // useAzCliAuthKey is the key we use in config to denote that we want to use the az CLI for authentication instead of // managing it ourselves. The value should be a string as specified by [strconv.ParseBool]. const useAzCliAuthKey = "auth.useAzCliAuth" // authConfigFileName is the name of the file we store in the user configuration directory which is used to persist // auth related configuration information (e.g. the home account id of the current user). This information is not secret. const authConfigFileName = "auth.json" // azurePipelinesSystemAccessTokenEnvVarName is the name of the environment variable that contains the system access token // used to auth against the ODIC endpoint for Azure Pipelines. It needs to be set by the task that runs the azd command by // adding `SYSTEM_ACCESSTOKEN: $(System.AccessToken)` to the `env` section of the task configuration. const azurePipelinesSystemAccessTokenEnvVarName = "SYSTEM_ACCESSTOKEN" // errNoSystemAccessTokenEnvVar is returned when the System.AccessToken environment variable is not set. var errNoSystemAccessTokenEnvVar = fmt.Errorf( "system access token not found, ensure the System.AccessToken value is mapped to an environment variable named %s", azurePipelinesSystemAccessTokenEnvVarName) // HttpClient interface as required by MSAL library. type HttpClient interface { // Do sends an HTTP request and returns an HTTP response. Do(*http.Request) (*http.Response, error) // CloseIdleConnections closes any idle connections in a "keep-alive" state. CloseIdleConnections() } // Manager manages the authentication system of azd. It allows a user to log in, either as a user principal or service // principal. Manager stores information so that the user can stay logged in across invocations of the CLI. When logged in // as a user (either interactively or via a device code flow), we provide a durable cache to MSAL which is used to cache // information to allow silent logins across process runs. This cache is stored inside the user's home directory, ACL'd such // that it can only be read by the current user. In addition, on Windows, this cache is encrypted, using CryptProtectData. // The home account id of the signed in user is stored as a property under [cCurrentUserKey]. This behavior matches the // AZ CLI. // // When logged in as a service principal, the same cache strategy that backed the MSAL cache is used to store the private // key or secret and the public components (the client ID and tenant ID) are stored under [cCurrentUserKey]. // // Logging out removes this cached authentication data. // // You can configure azd to ignore its native credential system and instead delegate to AZ CLI (useful for cases where azd // does not yet support your preferred method of authentication by setting [cUseLegacyAzCliAuthKey] in config to true. type Manager struct { publicClient publicClient publicClientOptions []public.Option cloud *cloud.Cloud configManager config.FileConfigManager userConfigManager config.UserConfigManager credentialCache Cache ghClient *github.FederatedTokenClient httpClient HttpClient console input.Console externalAuthCfg ExternalAuthConfiguration } type ExternalAuthConfiguration struct { Endpoint string Key string Transporter policy.Transporter } func NewManager( configManager config.FileConfigManager, userConfigManager config.UserConfigManager, cloud *cloud.Cloud, httpClient HttpClient, console input.Console, externalAuthCfg ExternalAuthConfiguration, ) (*Manager, error) { cfgRoot, err := config.GetUserConfigDir() if err != nil { return nil, fmt.Errorf("getting config dir: %w", err) } authRoot := filepath.Join(cfgRoot, "auth") if err := os.MkdirAll(authRoot, osutil.PermissionDirectoryOwnerOnly); err != nil { return nil, fmt.Errorf("creating auth root: %w", err) } cacheRoot := filepath.Join(authRoot, "msal") if err := os.MkdirAll(cacheRoot, osutil.PermissionDirectoryOwnerOnly); err != nil { return nil, fmt.Errorf("creating msal cache root: %w", err) } authorityUrl, err := url.JoinPath(cloud.Configuration.ActiveDirectoryAuthorityHost, "organizations") if err != nil { return nil, fmt.Errorf("joining authority url: %w", err) } options := []public.Option{ public.WithCache(newCache(cacheRoot)), public.WithAuthority(authorityUrl), public.WithHTTPClient(httpClient), } publicClientApp, err := public.New(azdClientID, options...) if err != nil { return nil, fmt.Errorf("creating msal client: %w", err) } ghClient := github.NewFederatedTokenClient(nil) return &Manager{ publicClient: &msalPublicClientAdapter{client: &publicClientApp}, publicClientOptions: options, cloud: cloud, configManager: configManager, userConfigManager: userConfigManager, credentialCache: newCredentialCache(authRoot), ghClient: ghClient, httpClient: httpClient, console: console, externalAuthCfg: externalAuthCfg, }, nil } // LoginScopes returns the scopes that we request an access token for when checking if a user is signed in. func LoginScopes(cloud *cloud.Cloud) []string { resourceManagerUrl := cloud.Configuration.Services[azcloud.ResourceManager].Endpoint return []string{ fmt.Sprintf("%s//.default", resourceManagerUrl), } } func (m *Manager) LoginScopes() []string { return LoginScopes(m.cloud) } func loginScopesMap(cloud *cloud.Cloud) map[string]struct{} { resourceManagerUrl := cloud.Configuration.Services[azcloud.ResourceManager].Endpoint return map[string]struct{}{resourceManagerUrl: {}} } // EnsureLoggedInCredential uses the credential's GetToken method to ensure an access token can be fetched. // On success, the token we fetched is returned. func EnsureLoggedInCredential( ctx context.Context, credential azcore.TokenCredential, cloud *cloud.Cloud, ) (*azcore.AccessToken, error) { token, err := credential.GetToken(ctx, policy.TokenRequestOptions{ Scopes: LoginScopes(cloud), }) if err != nil { return &azcore.AccessToken{}, err } return &token, nil } // CredentialForCurrentUser returns a TokenCredential instance for the current user. If `auth.useLegacyAzCliAuth` is set to // a truthy value in config, an instance of azidentity.AzureCLICredential is returned instead. To accept the default options, // pass nil. func (m *Manager) CredentialForCurrentUser( ctx context.Context, options *CredentialForCurrentUserOptions, ) (azcore.TokenCredential, error) { if options == nil { options = &CredentialForCurrentUserOptions{} } if m.UseExternalAuth() { log.Printf("delegating auth to external process") return newRemoteCredential( m.externalAuthCfg.Endpoint, m.externalAuthCfg.Key, options.TenantID, m.externalAuthCfg.Transporter), nil } userConfig, err := m.userConfigManager.Load() if err != nil { return nil, fmt.Errorf("fetching current user: %w", err) } if shouldUseLegacyAuth(userConfig) { log.Printf("delegating auth to az since %s is set to true", useAzCliAuthKey) cred, err := azidentity.NewAzureCLICredential(&azidentity.AzureCLICredentialOptions{ TenantID: options.TenantID, }) if err != nil { return nil, fmt.Errorf("failed to create credential: %w: %w", err, ErrNoCurrentUser) } return cred, nil } authConfig, err := m.readAuthConfig() if err != nil { return nil, fmt.Errorf("reading auth config: %w", err) } currentUser, err := readUserProperties(authConfig) if errors.Is(err, ErrNoCurrentUser) { // User is not logged in, not using az credentials, try CloudShell if possible if runcontext.IsRunningInCloudShell() { cloudShellCredential, err := m.newCredentialFromCloudShell() if err != nil { return nil, err } return cloudShellCredential, nil } if oneauth.Supported && strings.EqualFold(os.Getenv("IsDevBox"), "True") { // Try logging in the active OS account. If that fails for any reason, tell the user to run `azd auth login`. if err := m.LoginWithBrokerAccount(); err == nil { if config, err := m.readAuthConfig(); err == nil { user, err := readUserProperties(config) if err == nil && user != nil && user.HomeAccountID != nil && *user.HomeAccountID != "" { tenant := options.TenantID if tenant == "" { tenant = "organizations" } authority := m.cloud.Configuration.ActiveDirectoryAuthorityHost + tenant return oneauth.NewCredential(authority, azdClientID, oneauth.CredentialOptions{ HomeAccountID: *user.HomeAccountID, }) } } } } return nil, ErrNoCurrentUser } if currentUser.HomeAccountID != nil { if currentUser.FromOneAuth { tenant := options.TenantID if tenant == "" { tenant = "organizations" } authority, err := url.JoinPath(m.cloud.Configuration.ActiveDirectoryAuthorityHost, tenant) if err != nil { return nil, fmt.Errorf("joining authority url: %w", err) } return oneauth.NewCredential(authority, azdClientID, oneauth.CredentialOptions{ HomeAccountID: *currentUser.HomeAccountID, NoPrompt: options.NoPrompt, }) } accounts, err := m.publicClient.Accounts(ctx) if err != nil { return nil, err } for i, account := range accounts { if account.HomeAccountID == *currentUser.HomeAccountID { if options.TenantID == "" { return newAzdCredential(m.publicClient, &accounts[i], m.cloud), nil } else { newAuthority := m.cloud.Configuration.ActiveDirectoryAuthorityHost + options.TenantID newOptions := make([]public.Option, 0, len(m.publicClientOptions)+1) newOptions = append(newOptions, m.publicClientOptions...) // It is important that this option comes after the saved public client options since it will // override the default authority. newOptions = append(newOptions, public.WithAuthority(newAuthority)) clientWithNewTenant, err := public.New(azdClientID, newOptions...) if err != nil { return nil, err } return newAzdCredential( &msalPublicClientAdapter{client: &clientWithNewTenant}, &accounts[i], m.cloud), nil } } } } else if currentUser.ManagedIdentity { clientID := "" if currentUser.ClientID != nil { clientID = *currentUser.ClientID } return m.newCredentialFromManagedIdentity(clientID) } else if currentUser.TenantID != nil && currentUser.ClientID != nil { // by default we used the stored tenant (i.e. the one provided with the tenant id parameter when a user ran // `azd auth login`), but we allow an override using the options bag, when // TenantID is non-empty and PreferFallbackTenant is not true. tenantID := *currentUser.TenantID if options.TenantID != "" { tenantID = options.TenantID } ps, err := m.loadSecret(*currentUser.TenantID, *currentUser.ClientID) if err != nil { return nil, fmt.Errorf("loading secret: %w: %w", err, ErrNoCurrentUser) } if ps.ClientSecret != nil { return m.newCredentialFromClientSecret(tenantID, *currentUser.ClientID, *ps.ClientSecret) } else if ps.ClientCertificate != nil { return m.newCredentialFromClientCertificate(tenantID, *currentUser.ClientID, *ps.ClientCertificate) } else if ps.FederatedAuth != nil && ps.FederatedAuth.TokenProvider != nil { return m.newCredentialFromFederatedTokenProvider( tenantID, *currentUser.ClientID, *ps.FederatedAuth.TokenProvider, ps.FederatedAuth.ServiceConnectionID) } } return nil, ErrNoCurrentUser } type ClaimsForCurrentUserOptions = CredentialForCurrentUserOptions // ClaimsForCurrentUser returns claims for the currently logged in user. func (m *Manager) ClaimsForCurrentUser(ctx context.Context, options *ClaimsForCurrentUserOptions) (TokenClaims, error) { if options == nil { options = &ClaimsForCurrentUserOptions{} } // The user's credential is used to obtain an access token. // This implementation works well even in cases where a remote credential protocol is used to provide the credential. cred, err := m.CredentialForCurrentUser(ctx, options) if err != nil { return TokenClaims{}, err } accessToken, err := cred.GetToken(ctx, policy.TokenRequestOptions{ Scopes: LoginScopes(m.cloud), TenantID: options.TenantID, }) if err != nil { return TokenClaims{}, err } claims, err := GetClaimsFromAccessToken(accessToken.Token) if err != nil { return TokenClaims{}, err } return claims, nil } func shouldUseLegacyAuth(cfg config.Config) bool { if useLegacyAuth, has := cfg.Get(useAzCliAuthKey); has { if use, err := strconv.ParseBool(useLegacyAuth.(string)); err == nil && use { return true } } return false } // GetLoggedInServicePrincipalTenantID returns the stored service principal's tenant ID. // // Service principals are fixed to a particular tenant. // // This can be used to determine if the tenant is fixed, and if so short circuit performance intensive tenant-switching // for service principals. func (m *Manager) GetLoggedInServicePrincipalTenantID(ctx context.Context) (*string, error) { if m.UseExternalAuth() { // When delegating to an external system, we have no way to determine what principal was used return nil, nil } cfg, err := m.userConfigManager.Load() if err != nil { return nil, fmt.Errorf("fetching current user: %w", err) } if shouldUseLegacyAuth(cfg) { // When delegating to az, we have no way to determine what principal was used return nil, nil } authCfg, err := m.readAuthConfig() if err != nil { return nil, fmt.Errorf("fetching auth config: %w", err) } currentUser, err := readUserProperties(authCfg) if err != nil { // No user is logged in, if running in CloudShell use tenant id from // CloudShell session (single tenant) if runcontext.IsRunningInCloudShell() { // Tenant ID is not required when requesting a token from CloudShell credential, err := m.CredentialForCurrentUser(ctx, nil) if err != nil { return nil, err } token, err := EnsureLoggedInCredential(ctx, credential, m.cloud) if err != nil { return nil, err } tenantId, err := GetTenantIdFromToken(token.Token) if err != nil { return nil, err } return &tenantId, nil } return nil, ErrNoCurrentUser } // Record type of account found if currentUser.TenantID != nil { tracing.SetGlobalAttributes(fields.AccountTypeKey.String(fields.AccountTypeServicePrincipal)) } if currentUser.HomeAccountID != nil { tracing.SetGlobalAttributes(fields.AccountTypeKey.String(fields.AccountTypeUser)) } return currentUser.TenantID, nil } func (m *Manager) newCredentialFromManagedIdentity(clientID string) (azcore.TokenCredential, error) { options := &azidentity.ManagedIdentityCredentialOptions{} if clientID != "" { options.ID = azidentity.ClientID(clientID) } cred, err := azidentity.NewManagedIdentityCredential(options) if err != nil { return nil, fmt.Errorf("creating credential: %w", err) } return cred, nil } func (m *Manager) newCredentialFromClientSecret( tenantID string, clientID string, clientSecret string, ) (azcore.TokenCredential, error) { options := &azidentity.ClientSecretCredentialOptions{ ClientOptions: azcore.ClientOptions{ Transport: m.httpClient, // TODO: Inject client options instead? this can be done if we're OK // using the default user agent string. Cloud: m.cloud.Configuration, }, } cred, err := azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret, options) if err != nil { return nil, fmt.Errorf("creating credential: %w: %w", err, ErrNoCurrentUser) } return cred, nil } func (m *Manager) newCredentialFromClientCertificate( tenantID string, clientID string, clientCertificate string, ) (azcore.TokenCredential, error) { certData, err := base64.StdEncoding.DecodeString(clientCertificate) if err != nil { return nil, fmt.Errorf("decoding certificate: %w: %w", err, ErrNoCurrentUser) } certs, key, err := azidentity.ParseCertificates(certData, nil) if err != nil { return nil, fmt.Errorf("parsing certificate: %w: %w", err, ErrNoCurrentUser) } options := &azidentity.ClientCertificateCredentialOptions{ ClientOptions: azcore.ClientOptions{ Transport: m.httpClient, // TODO: Inject client options instead? this can be done if we're OK // using the default user agent string. Cloud: m.cloud.Configuration, }, } cred, err := azidentity.NewClientCertificateCredential( tenantID, clientID, certs, key, options) if err != nil { return nil, fmt.Errorf("creating credential: %w: %w", err, ErrNoCurrentUser) } return cred, nil } func (m *Manager) newCredentialFromFederatedTokenProvider( tenantID string, clientID string, provider federatedTokenProvider, serviceConnectionID *string, ) (azcore.TokenCredential, error) { clientOptions := azcore.ClientOptions{ Transport: m.httpClient, // TODO: Inject client options instead? this can be done if we're OK // using the default user agent string. Cloud: m.cloud.Configuration, } switch provider { case gitHubFederatedTokenProvider: cred, err := azidentity.NewClientAssertionCredential( tenantID, clientID, func(ctx context.Context) (string, error) { federatedToken, err := m.ghClient.TokenForAudience(ctx, "api://AzureADTokenExchange") if err != nil { return "", fmt.Errorf("fetching federated token: %w", err) } return federatedToken, nil }, &azidentity.ClientAssertionCredentialOptions{ ClientOptions: clientOptions, }) if err != nil { return nil, fmt.Errorf("creating credential: %w", err) } return cred, nil case azurePipelinesFederatedTokenProvider: systemAccessToken := os.Getenv(azurePipelinesSystemAccessTokenEnvVarName) if systemAccessToken == "" { return nil, errNoSystemAccessTokenEnvVar } // Guard against the case where the service connection ID is not set because someone manually edited the json // files managed by `azd auth login`. if serviceConnectionID == nil { return nil, errors.New("service connection ID not found, please run `azd auth login` to authenticate") } cred, err := azidentity.NewAzurePipelinesCredential( tenantID, clientID, *serviceConnectionID, systemAccessToken, &azidentity.AzurePipelinesCredentialOptions{ ClientOptions: clientOptions, }, ) if err != nil { return nil, fmt.Errorf("creating credential: %w: %w", err, ErrNoCurrentUser) } return cred, nil default: return nil, fmt.Errorf("unsupported federated token provider: '%s'", string(provider)) } } func (m *Manager) newCredentialFromCloudShell() (azcore.TokenCredential, error) { return NewCloudShellCredential(m.httpClient), nil } // WithOpenUrl defines a custom strategy for browsing to the url. type WithOpenUrl func(url string) error // LoginInteractiveOptions holds the optional inputs for interactive login. type LoginInteractiveOptions struct { TenantID string RedirectPort int WithOpenUrl WithOpenUrl } // LoginInteractive opens a browser for authenticate the user. func (m *Manager) LoginInteractive( ctx context.Context, scopes []string, options *LoginInteractiveOptions) (azcore.TokenCredential, error) { if scopes == nil { scopes = m.LoginScopes() } acquireTokenOptions := []public.AcquireInteractiveOption{} if options == nil { options = &LoginInteractiveOptions{} } if options.RedirectPort > 0 { acquireTokenOptions = append( acquireTokenOptions, public.WithRedirectURI(fmt.Sprintf("http://localhost:%d", options.RedirectPort))) } if options.TenantID != "" { acquireTokenOptions = append(acquireTokenOptions, public.WithTenantID(options.TenantID)) } if options.WithOpenUrl != nil { acquireTokenOptions = append(acquireTokenOptions, public.WithOpenURL(options.WithOpenUrl)) } res, err := m.publicClient.AcquireTokenInteractive(ctx, scopes, acquireTokenOptions...) if err != nil { return nil, err } if err := m.saveLoginForPublicClient(res); err != nil { return nil, err } return newAzdCredential(m.publicClient, &res.Account, m.cloud), nil } // LoginWithBrokerAccount logs in an account provided by the system authentication broker via OneAuth. // For example, it will log in the user currently signed in to Windows. This method never prompts for // user interaction and returns an error when the broker doesn't provide an account. func (m *Manager) LoginWithBrokerAccount() error { accountID, err := oneauth.LogInSilently(azdClientID) if err == nil { err = m.saveUserProperties(&userProperties{ FromOneAuth: true, HomeAccountID: &accountID, }) } return err } // LoginWithOneAuth starts OneAuth's interactive login flow. func (m *Manager) LoginWithOneAuth(ctx context.Context, tenantID string, scopes []string) error { if len(scopes) == 0 { scopes = m.LoginScopes() } authority := m.cloud.Configuration.ActiveDirectoryAuthorityHost + tenantID accountID, err := oneauth.LogIn(authority, azdClientID, strings.Join(scopes, " ")) if err == nil { err = m.saveUserProperties(&userProperties{ FromOneAuth: true, HomeAccountID: &accountID, }) } return err } func (m *Manager) LoginWithDeviceCode( ctx context.Context, tenantID string, scopes []string, withOpenUrl WithOpenUrl) (azcore.TokenCredential, error) { if scopes == nil { scopes = m.LoginScopes() } options := []public.AcquireByDeviceCodeOption{} if tenantID != "" { options = append(options, public.WithTenantID(tenantID)) } if withOpenUrl == nil { withOpenUrl = browser.OpenURL } code, err := m.publicClient.AcquireTokenByDeviceCode(ctx, scopes, options...) if err != nil { return nil, err } url := "https://microsoft.com/devicelogin" if runcontext.IsRunningInCloudShell() { m.console.MessageUxItem(ctx, &ux.MultilineMessage{ Lines: []string{ // nolint:lll "Cloud Shell is automatically authenticated under the initial account used to sign in. Run 'azd auth login' only if you need to use a different account.", fmt.Sprintf( "To sign in, use a web browser to open the page %s and enter the code %s to authenticate.", output.WithUnderline("%s", url), output.WithBold("%s", code.UserCode()), ), }, }) } else { m.console.Message(ctx, fmt.Sprintf("Start by copying the next code: %s", output.WithBold("%s", code.UserCode()))) if err := withOpenUrl(url); err != nil { log.Println("error launching browser: ", err.Error()) m.console.Message(ctx, fmt.Sprintf("Error launching browser. Manually go to: %s", url)) } m.console.Message(ctx, "Waiting for you to complete authentication in the browser...") } res, err := code.AuthenticationResult(ctx) if err != nil { return nil, err } m.console.Message(ctx, "Device code authentication completed.") if err := m.saveLoginForPublicClient(res); err != nil { return nil, err } return newAzdCredential(m.publicClient, &res.Account, m.cloud), nil } func (m *Manager) LoginWithManagedIdentity(ctx context.Context, clientID string) (azcore.TokenCredential, error) { options := &azidentity.ManagedIdentityCredentialOptions{} if clientID != "" { options.ID = azidentity.ClientID(clientID) } cred, err := azidentity.NewManagedIdentityCredential(options) if err != nil { return nil, fmt.Errorf("creating credential: %w", err) } if err := m.saveLoginForManagedIdentity(clientID); err != nil { return nil, err } return cred, nil } func (m *Manager) LoginWithServicePrincipalSecret( ctx context.Context, tenantId, clientId, clientSecret string, ) (azcore.TokenCredential, error) { cred, err := azidentity.NewClientSecretCredential(tenantId, clientId, clientSecret, nil) if err != nil { return nil, fmt.Errorf("creating credential: %w", err) } if err := m.saveLoginForServicePrincipal( tenantId, clientId, &persistedSecret{ ClientSecret: &clientSecret, }, ); err != nil { return nil, err } return cred, nil } func (m *Manager) LoginWithServicePrincipalCertificate( ctx context.Context, tenantId, clientId string, certData []byte, ) (azcore.TokenCredential, error) { certs, key, err := azidentity.ParseCertificates(certData, nil) if err != nil { return nil, fmt.Errorf("parsing certificate: %w", err) } cred, err := azidentity.NewClientCertificateCredential(tenantId, clientId, certs, key, nil) if err != nil { return nil, fmt.Errorf("creating credential: %w", err) } encodedCert := base64.StdEncoding.EncodeToString(certData) if err := m.saveLoginForServicePrincipal( tenantId, clientId, &persistedSecret{ ClientCertificate: &encodedCert, }, ); err != nil { return nil, err } return cred, nil } func (m *Manager) LoginWithGitHubFederatedTokenProvider( ctx context.Context, tenantId, clientId string, ) (azcore.TokenCredential, error) { cred, err := m.newCredentialFromFederatedTokenProvider(tenantId, clientId, gitHubFederatedTokenProvider, nil) if err != nil { return nil, err } if err := m.saveLoginForServicePrincipal( tenantId, clientId, &persistedSecret{ FederatedAuth: &federatedAuth{ TokenProvider: &gitHubFederatedTokenProvider, }, }, ); err != nil { return nil, err } return cred, nil } func (m *Manager) LoginWithAzurePipelinesFederatedTokenProvider( ctx context.Context, tenantID string, clientID string, serviceConnectionID string, ) (azcore.TokenCredential, error) { systemAccessToken := os.Getenv(azurePipelinesSystemAccessTokenEnvVarName) if systemAccessToken == "" { return nil, errNoSystemAccessTokenEnvVar } options := &azidentity.AzurePipelinesCredentialOptions{ ClientOptions: azcore.ClientOptions{ Transport: m.httpClient, // TODO: Inject client options instead? this can be done if we're OK // using the default user agent string. Cloud: m.cloud.Configuration, }, } cred, err := azidentity.NewAzurePipelinesCredential(tenantID, clientID, serviceConnectionID, systemAccessToken, options) if err != nil { return nil, fmt.Errorf("creating credential: %w", err) } if err := m.saveLoginForServicePrincipal(tenantID, clientID, &persistedSecret{ FederatedAuth: &federatedAuth{ TokenProvider: &azurePipelinesFederatedTokenProvider, ServiceConnectionID: &serviceConnectionID, }, }); err != nil { return nil, err } return cred, nil } // Logout signs out the current user and removes any cached authentication information func (m *Manager) Logout(ctx context.Context) error { act, err := m.getSignedInAccount(ctx) if err != nil && !errors.Is(err, ErrNoCurrentUser) { return fmt.Errorf("fetching current user: %w", err) } if act != nil { if err := m.publicClient.RemoveAccount(ctx, *act); err != nil { return fmt.Errorf("removing account from msal cache: %w", err) } } cfg, err := m.readAuthConfig() if err != nil { return fmt.Errorf("loading config: %w", err) } // we are fine to ignore the error here, it just means there's nothing to clean up. currentUser, _ := readUserProperties(cfg) if currentUser != nil { if currentUser.FromOneAuth { if err := oneauth.Logout(azdClientID); err != nil { return fmt.Errorf("logging out of OneAuth: %w", err) } } else if currentUser.TenantID != nil && currentUser.ClientID != nil { // When logged in as a service principal, remove the stored credential if err := m.saveLoginForServicePrincipal( *currentUser.TenantID, *currentUser.ClientID, &persistedSecret{}, ); err != nil { return fmt.Errorf("removing authentication secrets: %w", err) } } } if err := cfg.Unset(currentUserKey); err != nil { return fmt.Errorf("un-setting current user: %w", err) } if err := m.saveAuthConfig(cfg); err != nil { return fmt.Errorf("saving config: %w", err) } return nil } func (m *Manager) UseExternalAuth() bool { return m.externalAuthCfg.Endpoint != "" && m.externalAuthCfg.Key != "" } func (m *Manager) saveLoginForPublicClient(res public.AuthResult) error { if err := m.saveUserProperties(&userProperties{HomeAccountID: &res.Account.HomeAccountID}); err != nil { return err } return nil } func (m *Manager) saveLoginForManagedIdentity(clientID string) error { props := &userProperties{ManagedIdentity: true} if clientID != "" { props.ClientID = &clientID } if err := m.saveUserProperties(props); err != nil { return err } return nil } func (m *Manager) saveLoginForServicePrincipal(tenantId, clientId string, secret *persistedSecret) error { if err := m.saveSecret(tenantId, clientId, secret); err != nil { return err } if err := m.saveUserProperties(&userProperties{ClientID: &clientId, TenantID: &tenantId}); err != nil { return err } return nil } // getSignedInAccount fetches the public.Account for the signed in user, or nil if one does not exist // (e.g when logged in with a service principal). func (m *Manager) getSignedInAccount(ctx context.Context) (*public.Account, error) { cfg, err := m.readAuthConfig() if err != nil { return nil, fmt.Errorf("fetching current user: %w", err) } currentUser, err := readUserProperties(cfg) if err != nil { return nil, ErrNoCurrentUser } if currentUser.HomeAccountID != nil { accounts, err := m.publicClient.Accounts(ctx) if err != nil { return nil, err } for _, account := range accounts { if account.HomeAccountID == *currentUser.HomeAccountID { return &account, nil } } } return nil, nil } // saveUserProperties writes the properties under [cCurrentUserKey], overwriting any existing value. func (m *Manager) saveUserProperties(user *userProperties) error { cfg, err := m.readAuthConfig() if err != nil { return fmt.Errorf("fetching current user: %w", err) } if err := cfg.Set(currentUserKey, *user); err != nil { return fmt.Errorf("setting account id in config: %w", err) } return m.saveAuthConfig(cfg) } // readAuthConfig loads the configuration from [cAuthConfigFileName] and returns a parsed version of it. If the config // file does not exist, an empty [config.Config] is returned, with no error. func (m *Manager) readAuthConfig() (config.Config, error) { cfgPath, err := config.GetUserConfigDir() if err != nil { return nil, fmt.Errorf("getting user config dir: %w", err) } authCfgFile := filepath.Join(cfgPath, authConfigFileName) authCfg, err := m.configManager.Load(authCfgFile) if err == nil { return authCfg, nil } else if !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("reading auth config: %w", err) } // We used to store auth related configuration in the user configuration file directly. If above file did not exist, // see if there is the data in the old location, and if so migrate it to the new location. This upgrades the old // format to the new format. userCfg, err := m.userConfigManager.Load() if err != nil { return nil, fmt.Errorf("reading user config: %w", err) } curUserData, has := userCfg.Get(currentUserKey) if !has { return config.NewEmptyConfig(), nil } authCfg = config.NewEmptyConfig() if err := authCfg.Set(currentUserKey, curUserData); err != nil { return nil, err } if err := m.saveAuthConfig(authCfg); err != nil { return nil, err } if err := userCfg.Unset(currentUserKey); err != nil { return nil, err } if err := m.userConfigManager.Save(userCfg); err != nil { return nil, err } return authCfg, nil } func (m *Manager) saveAuthConfig(c config.Config) error { cfgPath, err := config.GetUserConfigDir() if err != nil { return fmt.Errorf("getting user config dir: %w", err) } authCfgFile := filepath.Join(cfgPath, authConfigFileName) return m.configManager.Save(c, authCfgFile) } // persistedSecretLookupKey returns the cache key we use for a given tenantId, clientId pair. func persistedSecretLookupKey(tenantId, clientId string) string { return fmt.Sprintf("%s.%s", tenantId, clientId) } // loadSecret reads a secret from the credential cache for a given client and tenant. func (m *Manager) loadSecret(tenantId, clientId string) (*persistedSecret, error) { val, err := m.credentialCache.Read(persistedSecretLookupKey(tenantId, clientId)) if err != nil { return nil, err } var ps persistedSecret if err := json.Unmarshal(val, &ps); err != nil { return nil, err } return &ps, nil } // saveSecret writes a secret into the credential cache for a given client and tenant. func (m *Manager) saveSecret(tenantId, clientId string, ps *persistedSecret) error { data, err := json.Marshal(ps) if err != nil { return err } return m.credentialCache.Set(persistedSecretLookupKey(tenantId, clientId), data) } type CredentialForCurrentUserOptions struct { // NoPrompt controls whether the credential may prompt for user interaction. NoPrompt bool // The tenant ID to use when constructing the credential, instead of the default tenant. TenantID string } // persistedSecret is the model type for the value we store in the credential cache. It is logically a discriminated union // of the different supported authentication modes type persistedSecret struct { // The client secret. ClientSecret *string `json:"clientSecret,omitempty"` // The bytes of the client certificate, which can be presented to azidentity.ParseCertificates, encoded as a // base64 string. ClientCertificate *string `json:"clientCertificate,omitempty"` // The federated auth credential. FederatedAuth *federatedAuth `json:"federatedAuth,omitempty"` } // federated auth token providers var ( gitHubFederatedTokenProvider federatedTokenProvider = "github" azurePipelinesFederatedTokenProvider federatedTokenProvider = "azure-pipelines" ) // token provider for federated auth type federatedTokenProvider string // federatedAuth stores federated authentication information. type federatedAuth struct { // The auth token provider. Tokens are obtained by calling the provider as needed. TokenProvider *federatedTokenProvider `json:"tokenProvider,omitempty"` // The ID of the service connection to use for Azure Pipelines federated auth. This is only set when the TokenProvider // is "azure-pipelines". ServiceConnectionID *string `json:"serviceConnectionId,omitempty"` } // userProperties is the model type for the value we store in the user's config. It is logically a discriminated union of // either an home account id (when logging in using a public client) or a client and tenant id (when using a confidential // client). type userProperties struct { ManagedIdentity bool `json:"managedIdentity,omitempty"` HomeAccountID *string `json:"homeAccountId,omitempty"` FromOneAuth bool `json:"fromOneAuth,omitempty"` ClientID *string `json:"clientId,omitempty"` TenantID *string `json:"tenantId,omitempty"` } func readUserProperties(cfg config.Config) (*userProperties, error) { currentUser, has := cfg.Get(currentUserKey) if !has { return nil, ErrNoCurrentUser } data, err := json.Marshal(currentUser) if err != nil { return nil, err } user := userProperties{} if err := json.Unmarshal(data, &user); err != nil { return nil, err } return &user, nil } const ( EmailLoginType LoginType = "email" ClientIdLoginType LoginType = "clientId" ) type LoginType string type LogInDetails struct { LoginType LoginType Account string } // LogInDetails method for Manager to return login details func (m *Manager) LogInDetails(ctx context.Context) (*LogInDetails, error) { cfg, err := m.readAuthConfig() if err != nil { return nil, fmt.Errorf("fetching current user: %w", err) } currentUser, err := readUserProperties(cfg) if err != nil { return nil, ErrNoCurrentUser } if currentUser.HomeAccountID != nil { accounts, err := m.publicClient.Accounts(ctx) if err != nil { return nil, err } for _, account := range accounts { if account.HomeAccountID == *currentUser.HomeAccountID { return &LogInDetails{ LoginType: EmailLoginType, Account: account.PreferredUsername, }, nil } } } else if currentUser.ClientID != nil { return &LogInDetails{ LoginType: ClientIdLoginType, Account: *currentUser.ClientID, }, nil } return nil, ErrNoCurrentUser }