diff --git a/cli/azd/cmd/auth.go b/cli/azd/cmd/auth.go index 7f68cb507a1..20761b89edd 100644 --- a/cli/azd/cmd/auth.go +++ b/cli/azd/cmd/auth.go @@ -41,5 +41,13 @@ func authActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { ActionResolver: newLogoutAction, }) + group.Add("status", &actions.ActionDescriptorOptions{ + Command: newAuthStatusCmd(), + FlagsResolver: newAuthStatusFlags, + ActionResolver: newAuthStatusAction, + OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, + DefaultFormat: output.NoneFormat, + }) + return group } diff --git a/cli/azd/cmd/auth_login.go b/cli/azd/cmd/auth_login.go index 69d5b2e4648..10f58af5bfa 100644 --- a/cli/azd/cmd/auth_login.go +++ b/cli/azd/cmd/auth_login.go @@ -350,6 +350,15 @@ func (la *loginAction) Run(ctx context.Context) (*actions.ActionResult, error) { LoggedInAs: details.Account, LoginType: ux.LoginType(details.LoginType), }) + + // Display token expiration information + if res.ExpiresOn != nil { + expiryMsg := fmt.Sprintf( + "\nCredential expires on: %s", + res.ExpiresOn.Format("2006-01-02 15:04:05 MST")) + la.console.Message(ctx, expiryMsg) + } + return nil, nil } } diff --git a/cli/azd/cmd/auth_status.go b/cli/azd/cmd/auth_status.go new file mode 100644 index 00000000000..66289c1981b --- /dev/null +++ b/cli/azd/cmd/auth_status.go @@ -0,0 +1,153 @@ +// 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: "Check the authentication status.", + Long: "Check the authentication status. Returns information about the logged-in user and when credentials expire.", + } +} + +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() + + // In check status mode, we always print the final status to stdout. + // We print any non-setup related errors to stderr. + // We always return a zero exit code. + token, err := a.verifyLoggedIn(ctx, scopes) + var loginExpiryError *auth.ReLoginRequiredError + if err != nil && + !errors.Is(err, auth.ErrNoCurrentUser) && + !errors.As(err, &loginExpiryError) { + fmt.Fprintln(a.console.Handles().Stderr, err.Error()) + } + + res := contracts.LoginResult{} + if err != nil { + res.Status = contracts.LoginStatusUnauthenticated + } else { + res.Status = contracts.LoginStatusSuccess + res.ExpiresOn = &token.ExpiresOn + } + + if a.formatter.Kind() != output.NoneFormat { + return nil, a.formatter.Format(res, a.writer, nil) + } else { + var msg string + switch res.Status { + case contracts.LoginStatusSuccess: + msg = "Logged in to Azure" + case contracts.LoginStatusUnauthenticated: + msg = "Not logged in, run `azd auth login` to login to Azure" + default: + panic("Unhandled login status") + } + + // get user account information + details, err := a.authManager.LogInDetails(ctx) + + // error getting user account or not logged in + if err != nil { + log.Printf("error: getting signed in account: %v", err) + fmt.Fprintln(a.console.Handles().Stdout, msg) + return nil, nil + } + + // only print the message if the user is logged in + a.console.MessageUxItem(ctx, &ux.LoggedIn{ + LoggedInAs: details.Account, + LoginType: ux.LoginType(details.LoginType), + }) + + // Display token expiration information + if res.ExpiresOn != nil { + expiryMsg := fmt.Sprintf( + "\nCredential expires on: %s", + res.ExpiresOn.Format("2006-01-02 15:04:05 MST")) + a.console.Message(ctx, expiryMsg) + } + + 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 11f41f1eb82..99a4cfd712f 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: 'Check the authentication status.', + }, ], }, { @@ -1540,6 +1544,10 @@ const completionSpec: Fig.Spec = { name: ['logout'], description: 'Log out of Azure.', }, + { + name: ['status'], + description: 'Check the 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..934987fe0a4 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-auth-status.snap @@ -0,0 +1,16 @@ + +Check the 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..b97ffc75d8a 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 : Check the authentication status. Global Flags -C, --cwd string : Sets the current working directory.