diff --git a/cli/azd/cmd/auth.go b/cli/azd/cmd/auth.go index 7f68cb507a1..17fcd17b654 100644 --- a/cli/azd/cmd/auth.go +++ b/cli/azd/cmd/auth.go @@ -36,6 +36,14 @@ func authActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { DefaultFormat: output.NoneFormat, }) + group.Add("status", &actions.ActionDescriptorOptions{ + Command: newAuthStatusCmd(), + FlagsResolver: newAuthStatusFlags, + ActionResolver: newAuthStatusAction, + OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, + DefaultFormat: output.NoneFormat, + }) + group.Add("logout", &actions.ActionDescriptorOptions{ Command: newLogoutCmd("auth"), ActionResolver: newLogoutAction, diff --git a/cli/azd/cmd/auth_status.go b/cli/azd/cmd/auth_status.go new file mode 100644 index 00000000000..edf8227f252 --- /dev/null +++ b/cli/azd/cmd/auth_status.go @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "log" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/auth" + "github.com/azure/azure-dev/cli/azd/pkg/contracts" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type authStatusFlags struct { + global *internal.GlobalCommandOptions +} + +func newAuthStatusFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *authStatusFlags { + flags := &authStatusFlags{} + flags.Bind(cmd.Flags(), global) + return flags +} + +func (f *authStatusFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { + f.global = global +} + +func newAuthStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show the current authentication status.", + Long: "Display whether you are logged in to Azure and the associated account information.", + } +} + +type authStatusAction struct { + formatter output.Formatter + writer io.Writer + console input.Console + authManager *auth.Manager + flags *authStatusFlags +} + +func newAuthStatusAction( + formatter output.Formatter, + writer io.Writer, + authManager *auth.Manager, + flags *authStatusFlags, + console input.Console, +) actions.Action { + return &authStatusAction{ + formatter: formatter, + writer: writer, + console: console, + authManager: authManager, + flags: flags, + } +} + +func (a *authStatusAction) Run(ctx context.Context) (*actions.ActionResult, error) { + scopes := a.authManager.LoginScopes() + + // get user account information + details, err := a.authManager.LogInDetails(ctx) + var loginExpiryError *auth.ReLoginRequiredError + if err != nil { + if !errors.Is(err, auth.ErrNoCurrentUser) && + !errors.As(err, &loginExpiryError) { + // print a useful message for unknown errors + fmt.Fprintln(a.console.Handles().Stderr, err.Error()) + } + log.Printf("error: getting signed in account: %v", err) + } + + res := contracts.StatusResult{} + if err != nil { + res.Status = contracts.AuthStatusUnauthenticated + } else { + res.Status = contracts.AuthStatusAuthenticated + _, err := a.verifyLoggedIn(ctx, scopes) + if err != nil { + res.Status = contracts.AuthStatusUnauthenticated + log.Printf("error: verifying logged in status: %v", err) + } + + switch details.LoginType { + case auth.EmailLoginType: + res.Type = contracts.AccountTypeUser + res.Email = details.Account + case auth.ClientIdLoginType: + res.Type = contracts.AccountTypeServicePrincipal + res.ClientID = details.Account + } + } + + if a.formatter.Kind() != output.NoneFormat { + a.formatter.Format(res, a.writer, nil) + return nil, nil + } + + a.console.MessageUxItem(ctx, &ux.AuthStatusView{Result: &res}) + return nil, nil +} + +// Verifies that the user has credentials stored, +// and that the credentials stored is accepted by the identity server (can be exchanged for access token). +func (a *authStatusAction) verifyLoggedIn(ctx context.Context, scopes []string) (*azcore.AccessToken, error) { + cred, err := a.authManager.CredentialForCurrentUser(ctx, nil) + if err != nil { + return nil, err + } + + // Ensure credential is valid, and can be exchanged for an access token + token, err := cred.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: scopes, + }) + + if err != nil { + return nil, err + } + + return &token, nil +} diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 23adaa4b8a6..1b097f53404 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -277,6 +277,10 @@ const completionSpec: Fig.Spec = { name: ['logout'], description: 'Log out of Azure.', }, + { + name: ['status'], + description: 'Show the current authentication status.', + }, ], }, { @@ -1605,6 +1609,10 @@ const completionSpec: Fig.Spec = { name: ['logout'], description: 'Log out of Azure.', }, + { + name: ['status'], + description: 'Show the current authentication status.', + }, ], }, { diff --git a/cli/azd/cmd/testdata/TestUsage-azd-auth-status.snap b/cli/azd/cmd/testdata/TestUsage-azd-auth-status.snap new file mode 100644 index 00000000000..14dec11d09f --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-auth-status.snap @@ -0,0 +1,16 @@ + +Show the current authentication status. + +Usage + azd auth status [flags] + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd auth status in your web browser. + -h, --help : Gets help for status. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-auth.snap b/cli/azd/cmd/testdata/TestUsage-azd-auth.snap index 21a262409d3..718e200775f 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-auth.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-auth.snap @@ -7,6 +7,7 @@ Usage Available Commands login : Log in to Azure. logout : Log out of Azure. + status : Show the current authentication status. Global Flags -C, --cwd string : Sets the current working directory. diff --git a/cli/azd/pkg/contracts/auth.go b/cli/azd/pkg/contracts/auth.go new file mode 100644 index 00000000000..369f406da73 --- /dev/null +++ b/cli/azd/pkg/contracts/auth.go @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package contracts + +import "time" + +// LoginStatus are the values of the "status" property of a LoginResult +type LoginStatus string + +const ( + // The user is logged in and we were able to obtain an access token for them. + // The "ExpiresOn" property of the result will contain information on when the + // access token expires. + LoginStatusSuccess LoginStatus = "success" + // The user is not logged in. + LoginStatusUnauthenticated LoginStatus = "unauthenticated" +) + +// LoginResult is the contract for the output of `azd auth login`. +type LoginResult struct { + // The result of checking for a valid access token. + Status LoginStatus `json:"status"` + // When status is `LoginStatusSuccess`, the time at which the access token + // expires. + ExpiresOn *time.Time `json:"expiresOn,omitempty"` +} + +// AuthStatus represents the authentication state for `azd auth status`. +type AuthStatus string + +const ( + AuthStatusAuthenticated AuthStatus = "authenticated" + AuthStatusUnauthenticated AuthStatus = "unauthenticated" +) + +// AccountType represents the type of account signed in. +type AccountType string + +const ( + // AccountTypeUser indicates a user account (email-based login). + AccountTypeUser AccountType = "user" + // AccountTypeServicePrincipal indicates a service principal (client ID-based login). + AccountTypeServicePrincipal AccountType = "servicePrincipal" +) + +// StatusResult is the contract for the output of `azd auth status`. +type StatusResult struct { + // The authentication state. + // When value is AuthStatusUnauthenticated, the user is not logged in and no other + // properties will be set. + Status AuthStatus `json:"status"` + + // The type of account signed in. + Type AccountType `json:"type,omitempty"` + + // The email of the signed-in user. Only set when Type is AccountTypeUser. + Email string `json:"email,omitempty"` + + // The client ID of the service principal. Only set when Type is AccountTypeServicePrincipal. + ClientID string `json:"clientId,omitempty"` +} diff --git a/cli/azd/pkg/contracts/auth_token.go b/cli/azd/pkg/contracts/auth_token.go index ee2a9937bfb..0f8bac942ee 100644 --- a/cli/azd/pkg/contracts/auth_token.go +++ b/cli/azd/pkg/contracts/auth_token.go @@ -8,7 +8,7 @@ import ( "time" ) -// AuthTokenResult is the value returned by `azd get-access-token`. It matches the shape of `azcore.AccessToken` +// AuthTokenResult is the value returned by `azd auth token`. It matches the shape of `azcore.AccessToken` type AuthTokenResult struct { // Token is the opaque access token, which may be provided to an Azure service. Token string `json:"token"` diff --git a/cli/azd/pkg/contracts/login.go b/cli/azd/pkg/contracts/login.go deleted file mode 100644 index 976349d640e..00000000000 --- a/cli/azd/pkg/contracts/login.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package contracts - -import "time" - -// LoginStatus are the values of the "status" property of a LoginResult -type LoginStatus string - -const ( - // The user is logged in and we were able to obtain an access token for them. - // The "ExpiresOn" property of the result will contain information on when the - // access token expires. - LoginStatusSuccess LoginStatus = "success" - // The user is not logged in. - LoginStatusUnauthenticated LoginStatus = "unauthenticated" -) - -// LoginResult is the contract for the output of `azd auth login`. -type LoginResult struct { - // The result of checking for a valid access token. - Status LoginStatus `json:"status"` - // When status is `LoginStatusSuccess`, the time at which the access token - // expires. - ExpiresOn *time.Time `json:"expiresOn,omitempty"` -} diff --git a/cli/azd/pkg/output/ux/auth_status.go b/cli/azd/pkg/output/ux/auth_status.go new file mode 100644 index 00000000000..8d575c62f2c --- /dev/null +++ b/cli/azd/pkg/output/ux/auth_status.go @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ux + +import ( + "encoding/json" + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/contracts" + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +// AuthStatusView renders a contracts.StatusResult for console output. +type AuthStatusView struct { + Result *contracts.StatusResult +} + +func (v *AuthStatusView) ToString(currentIndentation string) string { + if v.Result.Status == contracts.AuthStatusUnauthenticated { + return fmt.Sprintf("%sNot logged in, run `azd auth login` to login to Azure", currentIndentation) + } + + switch v.Result.Type { + case contracts.AccountTypeUser: + return fmt.Sprintf("%sLogged in to Azure as %s", + currentIndentation, + output.WithBold("%s", v.Result.Email)) + case contracts.AccountTypeServicePrincipal: + return fmt.Sprintf("%sLogged in to Azure as (%s)", + currentIndentation, + output.WithGrayFormat("%s", v.Result.ClientID)) + } + + return fmt.Sprintf("%sLogged in to Azure", currentIndentation) +} + +func (v *AuthStatusView) MarshalJSON() ([]byte, error) { + return json.Marshal(v.Result) +} diff --git a/cli/azd/test/functional/auth_test.go b/cli/azd/test/functional/auth_test.go index 19f667aa461..e9155ea1e5b 100644 --- a/cli/azd/test/functional/auth_test.go +++ b/cli/azd/test/functional/auth_test.go @@ -12,8 +12,10 @@ import ( "testing" "time" + "github.com/azure/azure-dev/cli/azd/pkg/contracts" "github.com/azure/azure-dev/cli/azd/test/azdcli" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_CLI_Auth_ExternalAuth(t *testing.T) { @@ -82,6 +84,7 @@ func Test_CLI_Auth_ExternalAuth(t *testing.T) { fmt.Sprintf("AZD_AUTH_KEY=%s", "a-fake-key"), ) + // First verify that we can get a token res, err := cli.RunCommand(ctx, "auth", "token", "--output=json") assert.NoError(t, err) assert.Equal(t, 0, res.ExitCode) @@ -96,4 +99,72 @@ func Test_CLI_Auth_ExternalAuth(t *testing.T) { assert.Equal(t, expectedToken, token.Token) assert.Equal(t, expectedExpiresOn, token.ExpiresOn) + + t.Run("auth status shows authenticated", func(t *testing.T) { + // Now verify that auth status shows authenticated + res, err = cli.RunCommand(ctx, "auth", "status", "--output", "json") + require.NoError(t, err) + require.Equal(t, 0, res.ExitCode) + + var status contracts.StatusResult + err = json.Unmarshal([]byte(res.Stdout), &status) + require.NoError(t, err) + + switch status.Status { + case contracts.AuthStatusUnauthenticated: + require.Fail(t, "User isn't currently logged in. Rerun this test with a logged in user to pass the test.") + case contracts.AuthStatusAuthenticated: + require.NotEmpty(t, string(status.Type)) + default: + require.Fail(t, "Unexpected auth status: %s", status.Status) + } + }) +} + +func Test_CLI_AuthStatus_Unauthenticated(t *testing.T) { + t.Parallel() + ctx, cancel := newTestContext(t) + defer cancel() + + dir := tempDirWithDiagnostics(t) + + cli := azdcli.NewCLI(t) + cli.WorkingDirectory = dir + cli.Env = append(os.Environ(), + "AZD_CONFIG_DIR="+dir, + "AZURE_DEV_COLLECT_TELEMETRY=no", + ) + + res, err := cli.RunCommand(ctx, "auth", "status", "--output", "json") + require.NoError(t, err) + require.Equal(t, 0, res.ExitCode) + + var status contracts.StatusResult + err = json.Unmarshal([]byte(res.Stdout), &status) + require.NoError(t, err) + + require.Equal(t, contracts.AuthStatusUnauthenticated, status.Status) + require.Empty(t, status.Type) + require.Empty(t, status.Email) + require.Empty(t, status.ClientID) +} + +func Test_CLI_AuthStatus_Unauthenticated_Text(t *testing.T) { + t.Parallel() + ctx, cancel := newTestContext(t) + defer cancel() + + dir := tempDirWithDiagnostics(t) + + cli := azdcli.NewCLI(t) + cli.WorkingDirectory = dir + cli.Env = append(os.Environ(), + "AZD_CONFIG_DIR="+dir, + "AZURE_DEV_COLLECT_TELEMETRY=no", + ) + + res, err := cli.RunCommand(ctx, "auth", "status") + require.NoError(t, err) + require.Equal(t, 0, res.ExitCode) + require.Contains(t, res.Stdout, "Not logged in") }