diff --git a/cli/azd/cmd/auth_status.go b/cli/azd/cmd/auth_status.go index cdfdba75ad1..b27dacc334a 100644 --- a/cli/azd/cmd/auth_status.go +++ b/cli/azd/cmd/auth_status.go @@ -96,10 +96,13 @@ func (a *authStatusAction) Run(ctx context.Context) (*actions.ActionResult, erro res.Status = contracts.AuthStatusUnauthenticated } else { res.Status = contracts.AuthStatusAuthenticated - _, err := a.verifyLoggedIn(ctx, scopes) + token, err := a.verifyLoggedIn(ctx, scopes) if err != nil { res.Status = contracts.AuthStatusUnauthenticated log.Printf("error: verifying logged in status: %v", err) + } else if token != nil { + expiresOn := contracts.RFC3339Time(token.ExpiresOn) + res.ExpiresOn = &expiresOn } switch details.LoginType { diff --git a/cli/azd/cmd/middleware/login_guard.go b/cli/azd/cmd/middleware/login_guard.go index e2a0270baf3..c7b87f9a978 100644 --- a/cli/azd/cmd/middleware/login_guard.go +++ b/cli/azd/cmd/middleware/login_guard.go @@ -5,9 +5,11 @@ package middleware import ( "context" + "errors" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" "github.com/azure/azure-dev/cli/azd/pkg/auth" "github.com/azure/azure-dev/cli/azd/pkg/cloud" @@ -53,6 +55,14 @@ func (l *LoginGuardMiddleware) Run(ctx context.Context, next NextFn) (*actions.A _, err = auth.EnsureLoggedInCredential(ctx, cred, l.authManager.Cloud()) if err != nil { + // Only wrap auth-specific errors with login guidance. + // Let cancellations, network errors, and transient failures propagate unchanged. + if errors.Is(err, auth.ErrNoCurrentUser) { + return nil, &internal.ErrorWithSuggestion{ + Err: err, + Suggestion: "Run 'azd auth login' to sign in before running this command.", + } + } return nil, err } diff --git a/cli/azd/pkg/contracts/auth.go b/cli/azd/pkg/contracts/auth.go index 369f406da73..6abce430167 100644 --- a/cli/azd/pkg/contracts/auth.go +++ b/cli/azd/pkg/contracts/auth.go @@ -59,4 +59,7 @@ type StatusResult struct { // The client ID of the service principal. Only set when Type is AccountTypeServicePrincipal. ClientID string `json:"clientId,omitempty"` + + // When authenticated, the time at which the current access token expires. + ExpiresOn *RFC3339Time `json:"expiresOn,omitempty"` } diff --git a/cli/azd/pkg/contracts/auth_token_test.go b/cli/azd/pkg/contracts/auth_token_test.go index 1255481c78e..89e9f91fe47 100644 --- a/cli/azd/pkg/contracts/auth_token_test.go +++ b/cli/azd/pkg/contracts/auth_token_test.go @@ -26,3 +26,47 @@ func TestRFC3339TimeJson(t *testing.T) { assert.Equal(t, `"2023-01-09T06:39:00.313323855Z"`, string(stdRes)) assert.Equal(t, `"2023-01-09T06:39:00Z"`, string(cusRes)) } + +func TestStatusResultJsonWithExpiresOn(t *testing.T) { + tm, err := time.Parse(time.RFC3339, "2026-03-22T14:30:00Z") + require.NoError(t, err) + + expiresOn := RFC3339Time(tm) + res := StatusResult{ + Status: AuthStatusAuthenticated, + Type: AccountTypeUser, + Email: "user@example.com", + ExpiresOn: &expiresOn, + } + + data, err := json.Marshal(res) + require.NoError(t, err) + + var parsed StatusResult + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, AuthStatusAuthenticated, parsed.Status) + assert.Equal(t, AccountTypeUser, parsed.Type) + assert.Equal(t, "user@example.com", parsed.Email) + require.NotNil(t, parsed.ExpiresOn) + assert.Equal(t, tm, time.Time(*parsed.ExpiresOn)) +} + +func TestStatusResultJsonWithoutExpiresOn(t *testing.T) { + res := StatusResult{ + Status: AuthStatusUnauthenticated, + } + + data, err := json.Marshal(res) + require.NoError(t, err) + + assert.NotContains(t, string(data), "expiresOn") + + var parsed StatusResult + err = json.Unmarshal(data, &parsed) + require.NoError(t, err) + + assert.Equal(t, AuthStatusUnauthenticated, parsed.Status) + assert.Nil(t, parsed.ExpiresOn) +}