fix(login): point headless users at the file token store, report real backend · Entire
fix(login): point headless users at the file token store, report real backend
e616b4c→main·
peyton-alt·3d ago·9 files·+408 added/-11 removed
Closes the residual gap in #1036: the mechanisms (ENTIRE_TOKEN_STORE=file,
ENTIRE_TOKEN) already exist, but a locked/absent OS keyring during
entire login surfaced a raw store error with no way forward, and
entire auth status claimed "stored in OS keychain" regardless of the
configured backend.
- login: when the credential-store write fails and the file backend is not already selected, append guidance naming ENTIRE_TOKEN_STORE=file and the concrete resolved token path. Store failures are tagged with auth.ErrCredentialStoreWrite at the two tokenstore.Set sites in RecordLoginContext, so the hint never fires for claim/context failures the file store would not help with.
- tokenstore: export BackendEnvVar/PathEnvVar, FileBackendSelected (the
single backend-selection predicate), FileBackendPath, and
BackendDescription;
auth statusnow reports the resolved backend (file path, or the per-OS keyring name). - tokenstore file backend: warn once per store (stderr) when the store file is group/other-accessible, naming the file and the chmod 0600 remediation. Deliberately a warning, not a refusal: provisioned files (CI secret mounts, read-only volumes) can carry modes the user cannot change, and a refusal would also block the login rewrite that restores 0600 and break diagnostic commands. Files written by the store were already 0600.
- error wording: "store ... token in keyring" -> "in credential store", since the failing backend may be the file store.
Fault injection in tests uses the existing UseFailingBackendForTesting deterministic under root), loose-permission fixtures chmod explicitly (immune to hardened umask), and both store-write sites, both BackendDescription branches, and the default token path are pinned.
Fixes #1036
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Sessions
01KXGTT2YYMN1Y1ZYB26M2NZT4View transcript
Changes
9
cmd/entire/cli
Mauth.go+2/-1
auth
Mcontexts.go+16/-2
Mauth_test.go+25
Mlogin.go+17/-1
Alogin_headless_hint_test.go+116
internal/entireclient/tokenstore
Mfile.go+29
Mfile_test.go+161
Mkeyring_timeout.go+1/-1
Mtokenstore.go+41/-6
16 unmodified lines
17
18
19
...
394 unmodified lines
418
419
420
420
421
422
423
424
"github.com/entireio/cli/cmd/entire/cli/palette"
"github.com/entireio/cli/internal/coreapi"
"github.com/entireio/cli/internal/entireclient/contexts"
"github.com/entireio/cli/internal/entireclient/tokenstore"
"github.com/spf13/cobra"
394 unmodified lines
if t.activeContext != "" {
writeAuthStatusLine(w, "Context:", t.activeContext)
}
writeAuthStatusLine(w, "Token:", "stored in OS keychain")
writeAuthStatusLine(w, "Token:", "stored in "+tokenstore.BackendDescription())
// Active sessions on this core. The token is already known good, so a
// listing failure is non-fatal — note it and carry on.
Mcmd/entire/cli/auth.go+2/-1
19 unmodified lines
20
21
22
23
24
25
26
27
28
29
30...
// so a conservative non-zero value is enough to keep the entry usable. const defaultContextTokenTTL = time.Hour
// ErrCredentialStoreWrite marks a failure writing tokens to the configured // credential backend (OS keyring or file store), as opposed to claim // validation or contexts.json failures. Login UX branches on it via // errors.Is to decide whether pointing the user at the file token store // would actually help. var ErrCredentialStoreWrite = errors.New("credential store write failed")
// credStoreWriteError tags an underlying store error with // ErrCredentialStoreWrite without changing its message. type credStoreWriteError struct{ inner error }
func (e *credStoreWriteError) Error() string { return e.inner.Error() } func (e *credStoreWriteError) Unwrap() []error { return []error{e.inner, ErrCredentialStoreWrite} }
// RecordLoginContext records a freshly obtained login token in the // shared contexts.json credential model: it derives the issuer (core // URL), handle, and expiry from the token's own claims, stores the token
...
refreshSlot := tokenstore.RefreshService(keychainService) if refreshToken != "" { if err := tokenstore.Set(refreshSlot, handle, refreshToken); err != nil { return "", fmt.Errorf("store refresh token in keyring: %w", err) return "", fmt.Errorf("store refresh token in credential store: %w", &credStoreWriteError{err}) } } else { _ = tokenstore.Delete(refreshSlot, handle) //nolint:errcheck // best-effort cleanup of a stale refresh token }
encoded := tokenstore.EncodeTokenWithExpiration(rawToken, expiresIn) if err := tokenstore.Set(keychainService, handle, encoded); err != nil { return "", fmt.Errorf("store login token in keyring: %w", err) return "", fmt.Errorf("store login token in credential store: %w", &credStoreWriteError{err}) }
var name string
// The Token: provenance line must reflect the configured credential backend: // with ENTIRE_TOKEN_STORE=file the token lives in a JSON file, not the OS // keychain, and claiming otherwise misleads exactly the headless users the // file backend exists for (#1036). func TestRunAuthStatus_FileTokenStoreProvenance(t *testing.T) { t.Setenv("ENTIRE_TOKEN_STORE", "file") t.Setenv("ENTIRE_TOKEN_STORE_PATH", "/ci/secrets/tokens.json")
target := statusTarget{coreURL: testCoreURL, token: "tok", activeContext: "core"} listSessions := func(context.Context, string, string) ([]api.AuthSession, error) { return nil, nil }
var out bytes.Buffer if err := runAuthStatus(context.Background(), &out, okProfile, listSessions, target); err != nil { t.Fatalf("unexpected error: %v", err) } got := out.String() if !strings.Contains(got, "stored in file /ci/secrets/tokens.json") { t.Fatalf("output = %q, want the file-backend provenance line", got) } if strings.Contains(got, "OS keychain") { t.Fatalf("output = %q, must not claim the OS keychain when the file backend is configured", got) } }