Skip to content

Commit 1403d56

Browse files
spboyerCopilot
authored andcommitted
Add auth pre-flight validation for agents (Azure#7236)
* Add auth pre-flight validation for agents (Azure#7234) Add --check flag to 'azd auth token' for lightweight auth validation. Agents can call 'azd auth token --check' to validate authentication state with exit code 0 (valid) or non-zero (invalid) without producing standard output. This prevents costly retry loops where agents speculatively call auth token and parse errors. Enhance 'azd auth status --output json' to include expiresOn field, giving agents machine-readable token expiry information for proactive re-authentication. Improve LoginGuardMiddleware to wrap ErrNoCurrentUser with actionable ErrorWithSuggestion guidance, while preserving original error types for cancellations and transient failures. Changes: - cmd/auth_token.go: Add --check flag with early-exit validation - cmd/auth_token_test.go: Add 3 test cases (check success/failure/not-logged-in) - cmd/auth_status.go: Populate ExpiresOn from token validation - pkg/contracts/auth.go: Add ExpiresOn field to StatusResult - cmd/middleware/login_guard.go: Wrap ErrNoCurrentUser with suggestion Fixes Azure#7234 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: remove redundant branches, add expiresOn tests - Remove redundant 'if a.flags.check' branches in auth_token.go that duplicated the same return (Copilot review comment #2) - Add StatusResult JSON serialization tests verifying expiresOn is present when authenticated and omitted when unauthenticated (Copilot review comment #3) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refactor: replace auth token --check with auth status exit code (Azure#7234) Instead of adding a --check flag to the hidden 'auth token' command, make the existing 'auth status --output json' command agent-friendly: - Exit non-zero when unauthenticated in machine-readable mode, so agents can rely on exit code without parsing output - expiresOn field already added to StatusResult in this PR - Remove --check flag and its tests (net -90 lines) Agents can now validate auth with: azd auth status --output json # exit 0 + JSON with expiresOn = valid # exit 1 + JSON with status:unauthenticated = invalid This is more discoverable than a hidden flag on a hidden command. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove tmp/ from tracking, add to .gitignore Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert "Remove tmp/ from tracking, add to .gitignore" This reverts commit 7253f21. * Remove tmp/ files from PR (not part of Azure#7234) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: exit non-zero in both modes, fix double output Per @JeffreyCA feedback: - Return auth.ErrNoCurrentUser when unauthenticated in both JSON and interactive modes (exit non-zero in all cases) - In JSON mode, format output before returning error to avoid double-print - In interactive mode, show status UX then exit non-zero Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert non-zero exit for unauthenticated status Per @vhvb1989 feedback: unauthenticated is a valid result, not a command failure. Non-zero exit should only be for unexpected errors. The expiresOn and LoginGuardMiddleware improvements remain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0a0d036 commit 1403d56

4 files changed

Lines changed: 61 additions & 1 deletion

File tree

cli/azd/cmd/auth_status.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,13 @@ func (a *authStatusAction) Run(ctx context.Context) (*actions.ActionResult, erro
9696
res.Status = contracts.AuthStatusUnauthenticated
9797
} else {
9898
res.Status = contracts.AuthStatusAuthenticated
99-
_, err := a.verifyLoggedIn(ctx, scopes)
99+
token, err := a.verifyLoggedIn(ctx, scopes)
100100
if err != nil {
101101
res.Status = contracts.AuthStatusUnauthenticated
102102
log.Printf("error: verifying logged in status: %v", err)
103+
} else if token != nil {
104+
expiresOn := contracts.RFC3339Time(token.ExpiresOn)
105+
res.ExpiresOn = &expiresOn
103106
}
104107

105108
switch details.LoginType {

cli/azd/cmd/middleware/login_guard.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ package middleware
55

66
import (
77
"context"
8+
"errors"
89

910
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
1011
"github.com/azure/azure-dev/cli/azd/cmd/actions"
12+
"github.com/azure/azure-dev/cli/azd/internal"
1113
"github.com/azure/azure-dev/cli/azd/internal/tracing/resource"
1214
"github.com/azure/azure-dev/cli/azd/pkg/auth"
1315
"github.com/azure/azure-dev/cli/azd/pkg/cloud"
@@ -53,6 +55,14 @@ func (l *LoginGuardMiddleware) Run(ctx context.Context, next NextFn) (*actions.A
5355

5456
_, err = auth.EnsureLoggedInCredential(ctx, cred, l.authManager.Cloud())
5557
if err != nil {
58+
// Only wrap auth-specific errors with login guidance.
59+
// Let cancellations, network errors, and transient failures propagate unchanged.
60+
if errors.Is(err, auth.ErrNoCurrentUser) {
61+
return nil, &internal.ErrorWithSuggestion{
62+
Err: err,
63+
Suggestion: "Run 'azd auth login' to sign in before running this command.",
64+
}
65+
}
5666
return nil, err
5767
}
5868

cli/azd/pkg/contracts/auth.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,7 @@ type StatusResult struct {
5959

6060
// The client ID of the service principal. Only set when Type is AccountTypeServicePrincipal.
6161
ClientID string `json:"clientId,omitempty"`
62+
63+
// When authenticated, the time at which the current access token expires.
64+
ExpiresOn *RFC3339Time `json:"expiresOn,omitempty"`
6265
}

cli/azd/pkg/contracts/auth_token_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,47 @@ func TestRFC3339TimeJson(t *testing.T) {
2626
assert.Equal(t, `"2023-01-09T06:39:00.313323855Z"`, string(stdRes))
2727
assert.Equal(t, `"2023-01-09T06:39:00Z"`, string(cusRes))
2828
}
29+
30+
func TestStatusResultJsonWithExpiresOn(t *testing.T) {
31+
tm, err := time.Parse(time.RFC3339, "2026-03-22T14:30:00Z")
32+
require.NoError(t, err)
33+
34+
expiresOn := RFC3339Time(tm)
35+
res := StatusResult{
36+
Status: AuthStatusAuthenticated,
37+
Type: AccountTypeUser,
38+
Email: "user@example.com",
39+
ExpiresOn: &expiresOn,
40+
}
41+
42+
data, err := json.Marshal(res)
43+
require.NoError(t, err)
44+
45+
var parsed StatusResult
46+
err = json.Unmarshal(data, &parsed)
47+
require.NoError(t, err)
48+
49+
assert.Equal(t, AuthStatusAuthenticated, parsed.Status)
50+
assert.Equal(t, AccountTypeUser, parsed.Type)
51+
assert.Equal(t, "user@example.com", parsed.Email)
52+
require.NotNil(t, parsed.ExpiresOn)
53+
assert.Equal(t, tm, time.Time(*parsed.ExpiresOn))
54+
}
55+
56+
func TestStatusResultJsonWithoutExpiresOn(t *testing.T) {
57+
res := StatusResult{
58+
Status: AuthStatusUnauthenticated,
59+
}
60+
61+
data, err := json.Marshal(res)
62+
require.NoError(t, err)
63+
64+
assert.NotContains(t, string(data), "expiresOn")
65+
66+
var parsed StatusResult
67+
err = json.Unmarshal(data, &parsed)
68+
require.NoError(t, err)
69+
70+
assert.Equal(t, AuthStatusUnauthenticated, parsed.Status)
71+
assert.Nil(t, parsed.ExpiresOn)
72+
}

0 commit comments

Comments
 (0)