auth: refresh login JWT for `auth status` / `logout` (no false re-login) · Entire
auth: refresh login JWT for auth status / logout (no false re-login)
ec61ea2→main· toothbrush·1mo ago·4 files·+120 added/-10 removed
status/logout resolved the active context's bearer with a raw keyring read (LoginTokenForContext), so an expired-but-refreshable session reported "re-login" — the exact false negative COR-389 fixed for control-plane commands, leaving entire activity (silently refreshes) inconsistent with entire auth status (told you to re-login) for the same token.
resolveStatusTarget now resolves the active context through a refreshing provider (auth.RefreshedLoginToken), falling back to the stored token when refresh fails so a genuinely dead session (ErrReauthRequired → expired token → /me 401) still surfaces "no longer valid". logout benefits too: the refreshed bearer authenticates the revoke call instead of failing on an expired token.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
2806fe7e05e7View transcript
Changes
4
cmd/entire/cli
Mauth.go+29/-6
auth
- Mrefresh.go+22
Mauth_context_test.go+65/-2
Mlogout.go+4/-2
166 unmodified lines
167
168
169
170
170
171
172
173
34 unmodified lines
208
209
210
211
212
213
214
215
216
217
218
5 unmodified lines
224
225
226
222
223
224
225
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
232
249
250
251
252
3 unmodified lines
256
257
258
259
260
261
262
263
264
265
266
267
166 unmodified lines
if err := requireSecureBaseURL(insecureHTTPAuth); err != nil {
return err
}
target, err := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, api.AuthBaseURL())
target, err := resolveStatusTarget(cmd.Context(), auth.NewContextStore(), auth.Contexts, auth.RefreshedLoginToken, api.AuthBaseURL())
if err != nil {
return err
}
34 unmodified lines
// name. Injected for testability; production wires auth.Contexts.
type contextsProvider func() ([]*contexts.Context, string, error)
// loginTokenResolver returns a usable login JWT for a context, transparently
// re-minting an expired one from the stored refresh token. Injected so status
// tests don't reach the network; production wires auth.RefreshedLoginToken.
type loginTokenResolver func(ctx context.Context, c *contexts.Context) (string, error)
// statusTarget is the resolved core to act against: the active context's
// CoreURL + its session token, or (no active context) the configured
// AuthBaseURL + legacy keyring entry. Shared by `auth status` (profile +
5 unmodified lines
totalContexts int
}
// resolveStatusTarget picks the core + token for `entire auth status`. The
// active contexts.json context wins (so `auth use` retargets status onto that
// login server); otherwise it falls back to the legacy keyring entry keyed by
// the configured auth host.
// resolveStatusTarget picks the core + token for `entire auth status` (and
// `logout`). The active contexts.json context wins (so `auth use` retargets
// status onto that login server); otherwise it falls back to the legacy keyring
// entry keyed by the configured auth host.
//
// For the active context the token is resolved through resolveLogin, which
// transparently re-mints an expired login JWT from the stored refresh token.
// This is the point of the refresh: an expired-but-refreshable session must
// report "logged in", not "re-login" — the same false negative
// auth.ResolveControlPlaneTarget already avoids for org/repo/project/grant.
// `logout` benefits too: the refreshed bearer can authenticate the revoke call
// instead of failing on an expired token. When refresh fails (revoked family,
// network, opaque token), we fall back to the stored token and let the /me
// liveness probe be the arbiter — preserving the accurate "no longer valid"
// outcome for a genuinely dead session (ErrReauthRequired → expired token →
// 401 → re-login).
//
// A genuine contexts.json read/parse error is surfaced, not swallowed — a
// missing file reads as "no contexts" (no error), so an error here means the
// file is corrupt or unreadable, which the user must see. This keeps status
// symmetric with the control-plane commands (auth.ResolveControlPlaneTarget),
// which fail the same way rather than silently degrading to a stale identity.
func resolveStatusTarget(store tokenStore, listContexts contextsProvider, fallbackBaseURL string) (statusTarget, error) {
func resolveStatusTarget(ctx context.Context, store tokenStore, listContexts contextsProvider, resolveLogin loginTokenResolver, fallbackBaseURL string) (statusTarget, error) {
all, current, err := listContexts()
if err != nil {
return statusTarget{}, fmt.Errorf("load contexts: %w", err)
}
3 unmodified lines
if c.Name != current || c.CoreURL == "" {
continue
}
// Prefer a refreshed token; fall back to the raw stored token so a
// refresh failure degrades to today's behaviour rather than dropping
// to the legacy entry.
if tok, terr := resolveLogin(ctx, c); terr == nil && tok != "" {
return statusTarget{coreURL: c.CoreURL, token: tok, activeContext: c.Name, totalContexts: total}, nil
}
if tok, terr := auth.LoginTokenForContext(c); terr == nil && tok != "" {
return statusTarget{coreURL: c.CoreURL, token: tok, activeContext: c.Name, totalContexts: total}, nil
}
Mcmd/entire/cli/auth.go+29/-6
215 unmodified lines
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
215 unmodified lines
}, nil
}
// RefreshedLoginToken returns context c's login JWT, transparently re-minting
// an expired one from the stored refresh token. It is the convenience form of
// NewRefreshingLoginProvider for callers that want a single token now (e.g.
// `auth status` / `logout`, which must report a refreshable session as alive
// rather than telling the user to re-login). The insecure-HTTP decision mirrors
// the control-plane resolver: loopback cores and the --insecure-http-auth
// opt-in are permitted, everything else requires https.
//
// Errors preserve the tokenmanager sentinels (ErrReauthRequired when the
// session is genuinely dead, ErrNotLoggedIn when no credential is usable) so
// callers can branch on errors.Is.
func RefreshedLoginToken(ctx context.Context, c *contexts.Context) (string, error) {
if c == nil {
return "", errors.New("nil context")
}
provider, err := NewRefreshingLoginProvider(c, nil, insecureHTTPEnabled() || isLoopbackHTTP(c.CoreURL))
if err != nil {
return "", err
}
return provider(ctx)
}
// NewRefreshingResourceProvider returns a provider that mints a bearer valid
// for resourceOrigin carrying the given audience, by exchanging context c's
// login JWT at c's own core (RFC 8693). It is NewRefreshingLoginProvider's
Mcmd/entire/cli/auth/refresh.go+22
1 unmodified line
2
3
4
5
6
7
8
3 unmodified lines
12
13
14
15
16
17
18
12 unmodified lines
31
32
33
32
34
35
36
37
8 unmodified lines
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
6 unmodified lines
119
120
121
59
122
123
124
125
1 unmodified line
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"os"
3 unmodified lines
"time"
"github.com/entireio/cli/cmd/entire/cli/auth"
"github.com/entireio/cli/internal/entireclient/contexts"
"github.com/entireio/cli/internal/entireclient/tokenstore"
"github.com/spf13/cobra"
)
12 unmodified lines
t.Fatalf("record context: %v", err)
}
got, err := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, "https://fallback.example.com")
got, err := resolveStatusTarget(t.Context(), auth.NewContextStore(), auth.Contexts, auth.RefreshedLoginToken, "https://fallback.example.com")
if err != nil {
t.Fatalf("resolveStatusTarget: %v", err)
}
8 unmodified lines
}
}
// TestResolveStatusTarget_PrefersRefreshedToken pins the fix: status uses the
// refreshed login JWT for the active context, so an expired-but-refreshable
// session reports "logged in" rather than the false "re-login" the raw read
// produced. The resolver returns a token distinct from what's stored; we assert
// status carries the refreshed one.
func TestResolveStatusTarget_PrefersRefreshedToken(t *testing.T) {
cfgDir := t.TempDir()
t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
t.Cleanup(restore)
// Stored token is expired; a raw read would 401 at /me → "re-login".
expired := time.Now().Add(-time.Hour).Unix()
if _, err := auth.RecordLoginContext(makeContextJWT(t, fmt.Sprintf(`{"iss":"https://eu.auth.entire.io","handle":"alice","exp":%d}`, expired)), "entr_refresh", true); err != nil {
t.Fatalf("record context: %v", err)
}
refreshed := func(_ context.Context, _ *contexts.Context) (string, error) { return "refreshed-jwt", nil }
got, err := resolveStatusTarget(t.Context(), auth.NewContextStore(), auth.Contexts, refreshed, "https://fallback.example.com")
if err != nil {
t.Fatalf("resolveStatusTarget: %v", err)
}
if got.token != "refreshed-jwt" {
t.Errorf("token = %q, want the refreshed token (not the stale stored one)", got.token)
}
if got.coreURL != "https://eu.auth.entire.io" {
t.Errorf("coreURL = %q, want the active context's CoreURL", got.coreURL)
}
}
// TestResolveStatusTarget_FallsBackToStoredWhenRefreshFails pins the safety net:
// when refresh fails (revoked family, network, opaque token) status drops to the
// stored token and lets the /me probe arbitrate — rather than skipping to the
// legacy entry or losing the active context.
func TestResolveStatusTarget_FallsBackToStoredWhenRefreshFails(t *testing.T) {
cfgDir := t.TempDir()
t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
t.Cleanup(restore)
exp := time.Now().Add(time.Hour).Unix()
stored := makeContextJWT(t, fmt.Sprintf(`{"iss":"https://eu.auth.entire.io","handle":"alice","exp":%d}`, exp))
if _, err := auth.RecordLoginContext(stored, "", true); err != nil {
t.Fatalf("record context: %v", err)
}
failRefresh := func(_ context.Context, _ *contexts.Context) (string, error) {
return "", auth.ErrNotLoggedIn
}
got, err := resolveStatusTarget(t.Context(), auth.NewContextStore(), auth.Contexts, failRefresh, "https://fallback.example.com")
if err != nil {
t.Fatalf("resolveStatusTarget: %v", err)
}
if got.token != stored {
t.Errorf("token = %q, want the stored token as fallback", got.token)
}
if got.coreURL != "https://eu.auth.entire.io" || got.activeContext == "" {
t.Errorf("want the active context preserved on fallback, got coreURL=%q activeContext=%q", got.coreURL, got.activeContext)
}
}
// A genuine contexts.json read/parse error is surfaced by resolveStatusTarget,
// symmetric with the control-plane commands — not swallowed into the legacy
// fallback. (A missing file reads as "no contexts" and is not an error.)
if err := os.WriteFile(filepath.Join(cfgDir, "contexts.json"), []byte("{ not valid json"), 0o600); err != nil {
t.Fatalf("write corrupt contexts.json: %v", err)
}
if _, err := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, "https://fallback.example.com"); err == nil {
if _, err := resolveStatusTarget(t.Context(), auth.NewContextStore(), auth.Contexts, auth.RefreshedLoginToken, "https://fallback.example.com"); err == nil {
t.Fatal("want an error when contexts.json is corrupt, got nil")
}
}
}