Skip to content

Commit 1b56290

Browse files
authored
Merge pull request #1750 from entireio/fix/1036-headless-login-hint
fix(login): headless keyring hint, real-backend provenance, loose-permissions warning
2 parents 2c03352 + 14f45ea commit 1b56290

10 files changed

Lines changed: 462 additions & 11 deletions

File tree

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ With Entire, you can:
2525
- [Key Concepts](#key-concepts)
2626
- [How It Works](#how-it-works)
2727
- [Strategy](#strategy)
28+
- [Headless & CI Authentication](#headless--ci-authentication)
2829
- [Local Device Auth Testing](#local-device-auth-testing)
2930
- [Commands Reference](#commands-reference)
3031
- [Configuration](#configuration)
@@ -206,6 +207,44 @@ Entire works seamlessly with [git worktrees](https://git-scm.com/docs/git-worktr
206207

207208
Multiple AI sessions can run on the same commit. If you start a second session while another has uncommitted work, Entire warns you and tracks them separately. Both sessions' checkpoints are preserved and can be rewound independently.
208209

210+
## Headless & CI Authentication
211+
212+
By default `entire login` stores tokens in the OS keyring (macOS Keychain,
213+
Linux Secret Service, Windows Credential Manager). Machines without a usable
214+
keyring — headless servers, containers, minimal VMs, CI runners — have two
215+
supported paths:
216+
217+
### Interactive login on a headless machine
218+
219+
Use the file-backed token store. The device-auth flow already works without a
220+
local browser (the CLI prints an approval URL you can open on any machine);
221+
only token storage needs the override:
222+
223+
```bash
224+
ENTIRE_TOKEN_STORE=file entire login
225+
```
226+
227+
Tokens are written with `0600` permissions to `tokens.json` in your Entire
228+
config directory (`~/.config/entire` by default). Override the location with
229+
`ENTIRE_TOKEN_STORE_PATH`. Set `ENTIRE_TOKEN_STORE=file` persistently (e.g. in
230+
your shell profile) so later commands read from the same store.
231+
232+
### Non-interactive automation (CI, workload identity)
233+
234+
Skip login and storage entirely by injecting a token per invocation:
235+
236+
```bash
237+
ENTIRE_TOKEN=<login-or-sa-session-JWT> entire ...
238+
```
239+
240+
`ENTIRE_TOKEN` bypasses stored credentials; the CLI derives the control-plane
241+
endpoint from the token itself. Nothing is written to disk. This is the right
242+
path for CI pipelines and service accounts.
243+
244+
> **Seeing `save login` / `failed to unlock correct collection` errors from
245+
> `entire login`?** That's the OS keyring being unavailable — use one of the
246+
> two paths above.
247+
209248
## Local Device Auth Testing
210249

211250
If you're working on the CLI device auth flow against a local `entire.io` checkout:

cmd/entire/cli/auth.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/entireio/cli/cmd/entire/cli/palette"
1818
"github.com/entireio/cli/internal/coreapi"
1919
"github.com/entireio/cli/internal/entireclient/contexts"
20+
"github.com/entireio/cli/internal/entireclient/tokenstore"
2021
"github.com/spf13/cobra"
2122
)
2223

@@ -417,7 +418,7 @@ func runAuthStatus(ctx context.Context, w io.Writer, fetchProfile profileFetcher
417418
if t.activeContext != "" {
418419
writeAuthStatusLine(w, "Context:", t.activeContext)
419420
}
420-
writeAuthStatusLine(w, "Token:", "stored in OS keychain")
421+
writeAuthStatusLine(w, "Token:", "stored in "+tokenstore.BackendDescription())
421422

422423
// Active sessions on this core. The token is already known good, so a
423424
// listing failure is non-fatal — note it and carry on.

cmd/entire/cli/auth/contexts.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ import (
2020
// so a conservative non-zero value is enough to keep the entry usable.
2121
const defaultContextTokenTTL = time.Hour
2222

23+
// ErrCredentialStoreWrite marks a failure writing tokens to the configured
24+
// credential backend (OS keyring or file store), as opposed to claim
25+
// validation or contexts.json failures. Login UX branches on it via
26+
// errors.Is to decide whether pointing the user at the file token store
27+
// would actually help.
28+
var ErrCredentialStoreWrite = errors.New("credential store write failed")
29+
30+
// credStoreWriteError tags an underlying store error with
31+
// ErrCredentialStoreWrite without changing its message.
32+
type credStoreWriteError struct{ inner error }
33+
34+
func (e *credStoreWriteError) Error() string { return e.inner.Error() }
35+
func (e *credStoreWriteError) Unwrap() []error { return []error{e.inner, ErrCredentialStoreWrite} }
36+
2337
// RecordLoginContext records a freshly obtained login token in the
2438
// shared contexts.json credential model: it derives the issuer (core
2539
// URL), handle, and expiry from the token's own claims, stores the token
@@ -82,15 +96,15 @@ func RecordLoginContext(rawToken, refreshToken string, activate bool) (string, e
8296
refreshSlot := tokenstore.RefreshService(keychainService)
8397
if refreshToken != "" {
8498
if err := tokenstore.Set(refreshSlot, handle, refreshToken); err != nil {
85-
return "", fmt.Errorf("store refresh token in keyring: %w", err)
99+
return "", fmt.Errorf("store refresh token in credential store: %w", &credStoreWriteError{err})
86100
}
87101
} else {
88102
_ = tokenstore.Delete(refreshSlot, handle) //nolint:errcheck // best-effort cleanup of a stale refresh token
89103
}
90104

91105
encoded := tokenstore.EncodeTokenWithExpiration(rawToken, expiresIn)
92106
if err := tokenstore.Set(keychainService, handle, encoded); err != nil {
93-
return "", fmt.Errorf("store login token in keyring: %w", err)
107+
return "", fmt.Errorf("store login token in credential store: %w", &credStoreWriteError{err})
94108
}
95109

96110
var name string

cmd/entire/cli/auth_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,28 @@ func TestAuthCmd_TopLevelLoginAndLogoutStillRegistered(t *testing.T) {
431431
}
432432
}
433433
}
434+
435+
// The Token: provenance line must reflect the configured credential backend:
436+
// with ENTIRE_TOKEN_STORE=file the token lives in a JSON file, not the OS
437+
// keychain, and claiming otherwise misleads exactly the headless users the
438+
// file backend exists for (#1036).
439+
func TestRunAuthStatus_FileTokenStoreProvenance(t *testing.T) {
440+
// Not parallel: t.Setenv.
441+
t.Setenv("ENTIRE_TOKEN_STORE", "file")
442+
t.Setenv("ENTIRE_TOKEN_STORE_PATH", "/ci/secrets/tokens.json")
443+
444+
target := statusTarget{coreURL: testCoreURL, token: "tok", activeContext: "core"}
445+
listSessions := func(context.Context, string, string) ([]api.AuthSession, error) { return nil, nil }
446+
447+
var out bytes.Buffer
448+
if err := runAuthStatus(context.Background(), &out, okProfile, listSessions, target); err != nil {
449+
t.Fatalf("unexpected error: %v", err)
450+
}
451+
got := out.String()
452+
if !strings.Contains(got, "stored in file /ci/secrets/tokens.json") {
453+
t.Fatalf("output = %q, want the file-backend provenance line", got)
454+
}
455+
if strings.Contains(got, "OS keychain") {
456+
t.Fatalf("output = %q, must not claim the OS keychain when the file backend is configured", got)
457+
}
458+
}

cmd/entire/cli/login.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/entireio/cli/cmd/entire/cli/api"
1818
"github.com/entireio/cli/cmd/entire/cli/auth"
1919
"github.com/entireio/cli/cmd/entire/cli/interactive"
20+
"github.com/entireio/cli/internal/entireclient/tokenstore"
2021
"github.com/spf13/cobra"
2122
)
2223

@@ -327,13 +328,28 @@ func persistLogin(outW io.Writer, baseURL, token, refreshToken string) error {
327328
// single store every consumer (control plane, data API, git remote
328329
// helper, entiredb's CLIs) resolves against.
329330
if _, err := auth.RecordLoginContext(token, refreshToken, true); err != nil {
330-
return fmt.Errorf("save login: %w", err)
331+
return fmt.Errorf("save login: %w", withHeadlessStoreHint(err))
331332
}
332333

333334
fmt.Fprintln(outW, "✓ Login complete.")
334335
return nil
335336
}
336337

338+
// withHeadlessStoreHint appends file-token-store guidance to a credential
339+
// store write failure. The default backend is the OS keyring, which locked
340+
// or keyring-less machines (CI, containers, minimal server VMs) can't use —
341+
// the raw store error gives those users no way forward (#1036). The hint is
342+
// skipped when ENTIRE_TOKEN_STORE=file is already set (suggesting it again
343+
// would be nonsense) and for failures the file store wouldn't help with.
344+
func withHeadlessStoreHint(err error) error {
345+
if !errors.Is(err, auth.ErrCredentialStoreWrite) || tokenstore.FileBackendSelected() {
346+
return err
347+
}
348+
349+
return fmt.Errorf("%w\n\nIf this machine has no usable OS keyring (headless server, container, CI), store tokens in a file instead:\n\n %s=file entire login\n\nTokens are then written with 0600 permissions to %s (override the location with %s)",
350+
err, tokenstore.BackendEnvVar, tokenstore.FileBackendPath(), tokenstore.PathEnvVar)
351+
}
352+
337353
// validateReceivedToken runs minimum-trust checks on the access token
338354
// the AS handed us before we persist it. The server is the authority
339355
// on signature/exp; this is defense in depth aimed at catching gross
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/entireio/cli/internal/entireclient/tokenstore"
12+
)
13+
14+
// failingTokenStore installs a backend whose Set always fails, standing in
15+
// for the locked/absent OS keyring a headless machine hits (#1036). Fault
16+
// injection (rather than filesystem permissions) keeps the failure
17+
// deterministic even when tests run as root, where permission bits don't
18+
// block writes.
19+
func failingTokenStore(t *testing.T) {
20+
t.Helper()
21+
restore := tokenstore.UseFailingBackendForTesting(
22+
filepath.Join(t.TempDir(), "tokens.json"),
23+
func(string, string) bool { return true },
24+
)
25+
t.Cleanup(restore)
26+
}
27+
28+
// loginTestJWT builds a token that passes validateReceivedToken and carries
29+
// the iss/handle claims RecordLoginContext keys on.
30+
func loginTestJWT(t *testing.T, issuer string) string {
31+
t.Helper()
32+
exp := time.Now().Add(time.Hour).Unix()
33+
return makeJWT(t, `{"alg":"RS256"}`, fmt.Sprintf(`{"iss":%q,"handle":"alice","exp":%d}`, issuer, exp))
34+
}
35+
36+
// A login that reaches token persistence and fails there must tell headless
37+
// users about the file token store: the default backend is the OS keyring,
38+
// and on keyring-less machines (CI, containers, minimal server VMs) the raw
39+
// store error gives no way forward (#1036). Both store-write sites are
40+
// covered: the refresh-token write (refreshToken != "") fails first when a
41+
// refresh token is present, and the login-token write is the first store
42+
// write when there is none.
43+
func TestPersistLogin_StoreWriteFailureIncludesHeadlessHint(t *testing.T) {
44+
for name, refreshToken := range map[string]string{
45+
"refresh-token write fails": "refresh-token",
46+
"login-token write fails": "",
47+
} {
48+
t.Run(name, func(t *testing.T) {
49+
// Not parallel: mutates the process-global tokenstore backend and
50+
// env. TestMain sets ENTIRE_TOKEN_STORE=file process-wide for
51+
// spawned-binary isolation; blank it so this test sees the
52+
// default-keyring condition a real user hits.
53+
t.Setenv("ENTIRE_TOKEN_STORE", "")
54+
failingTokenStore(t)
55+
56+
var out bytes.Buffer
57+
err := persistLogin(&out, "https://example.test", loginTestJWT(t, "https://example.test"), refreshToken)
58+
if err == nil {
59+
t.Fatal("persistLogin should fail when the token store rejects writes")
60+
}
61+
if !strings.Contains(err.Error(), "ENTIRE_TOKEN_STORE=file") {
62+
t.Fatalf("store-write failure should point headless users at the file token store, got:\n%v", err)
63+
}
64+
if !strings.Contains(err.Error(), "ENTIRE_TOKEN_STORE_PATH") {
65+
t.Fatalf("hint should mention the path override, got:\n%v", err)
66+
}
67+
})
68+
}
69+
}
70+
71+
// When the user is already on the file backend, suggesting
72+
// ENTIRE_TOKEN_STORE=file would be nonsense — the raw error must pass
73+
// through without the headless hint.
74+
func TestPersistLogin_StoreWriteFailureOnFileBackend_NoHint(t *testing.T) {
75+
// Not parallel: mutates the process-global tokenstore backend and env.
76+
t.Setenv("ENTIRE_TOKEN_STORE", "file")
77+
failingTokenStore(t)
78+
79+
var out bytes.Buffer
80+
err := persistLogin(&out, "https://example.test", loginTestJWT(t, "https://example.test"), "refresh-token")
81+
if err == nil {
82+
t.Fatal("persistLogin should fail when the token store rejects writes")
83+
}
84+
// Assert on the hint's structural markers, not its prose: the underlying
85+
// store error can never contain these, so the assertion stays meaningful
86+
// if the hint wording changes.
87+
if strings.Contains(err.Error(), "=file entire login") || strings.Contains(err.Error(), "ENTIRE_TOKEN_STORE_PATH") {
88+
t.Fatalf("hint must not appear when the file backend is already configured, got:\n%v", err)
89+
}
90+
if !strings.Contains(err.Error(), "save login") {
91+
t.Fatalf("underlying save failure should still surface, got:\n%v", err)
92+
}
93+
}
94+
95+
// Failures unrelated to the credential store (here: a token whose issuer
96+
// doesn't match the login server) must not carry the keyring hint — the
97+
// file store wouldn't help.
98+
func TestPersistLogin_NonStoreFailure_NoHint(t *testing.T) {
99+
// Not parallel: mutates process-global env.
100+
t.Setenv("ENTIRE_TOKEN_STORE", "")
101+
restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
102+
t.Cleanup(restore)
103+
104+
exp := time.Now().Add(time.Hour).Unix()
105+
// iss mismatch with baseURL fails validateReceivedToken before any store write.
106+
token := makeJWT(t, `{"alg":"RS256"}`, fmt.Sprintf(`{"iss":"https://other.test","handle":"alice","exp":%d}`, exp))
107+
108+
var out bytes.Buffer
109+
err := persistLogin(&out, "https://example.test", token, "refresh-token")
110+
if err == nil {
111+
t.Fatal("persistLogin should reject a token from the wrong issuer")
112+
}
113+
if strings.Contains(err.Error(), "ENTIRE_TOKEN_STORE") {
114+
t.Fatalf("non-store failure must not carry the token-store hint, got:\n%v", err)
115+
}
116+
}

internal/entireclient/tokenstore/file.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,37 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"io"
89
"os"
910
"path/filepath"
11+
"runtime"
1012
"sync"
1113
"time"
1214

1315
"github.com/gofrs/flock"
1416
)
1517

18+
// goosWindows is runtime.GOOS on Windows, where unix permission bits don't
19+
// exist (Go reports synthetic modes) so permission checks are skipped.
20+
const goosWindows = "windows"
21+
22+
// loosePermsWarnW receives the loose-permissions warning. Package-level so
23+
// tests can capture it; production always writes to stderr (matching the
24+
// unlock warning in withFileLock).
25+
var loosePermsWarnW io.Writer = os.Stderr
26+
1627
// fileStore persists credentials as a JSON file on disk.
1728
// The file format is: { "service": { "user": "password" } }
1829
type fileStore struct {
1930
path string
2031
mu sync.Mutex
32+
// warnedLoosePerms dedupes the loose-permissions warning to once per
33+
// store instance — effectively once per CLI invocation, since
34+
// currentBackend caches a single fileStore for the process. Like the
35+
// rest of the store's state it relies on mu, which every production
36+
// caller of load (Get/Set/Delete) holds; tests that call load directly
37+
// are single-goroutine.
38+
warnedLoosePerms bool
2139
}
2240

2341
// withFileLock runs fn while holding an exclusive flock on f.path + ".lock".
@@ -48,6 +66,21 @@ func (f *fileStore) withFileLock(fn func() error) error {
4866
}
4967

5068
func (f *fileStore) load() (map[string]map[string]string, error) {
69+
// The file holds bearer tokens; warn (once per store) when it is
70+
// readable or writable by group/others. Deliberately a warning, not a
71+
// refusal: externally provisioned files (CI secret mounts, read-only
72+
// volumes) often carry modes the user cannot change, a hard refusal
73+
// would also block the login rewrite that restores 0600, and diagnostic
74+
// commands must keep working so the user can see their auth state.
75+
// Files written by save() are always 0600, so this only fires on files
76+
// created or chmod-ed outside this store. Windows has no unix permission
77+
// bits — Go reports synthetic modes there — so the check is unix-only.
78+
if runtime.GOOS != goosWindows && !f.warnedLoosePerms {
79+
if info, statErr := os.Stat(f.path); statErr == nil && info.Mode().Perm()&0o077 != 0 {
80+
f.warnedLoosePerms = true
81+
fmt.Fprintf(loosePermsWarnW, "Warning: token store %s is accessible by group/others (mode %04o) and holds bearer tokens; run: chmod 0600 %s\n", f.path, info.Mode().Perm(), f.path)
82+
}
83+
}
5184
data, err := os.ReadFile(f.path)
5285
if err != nil {
5386
if os.IsNotExist(err) {

0 commit comments

Comments
 (0)