Skip to content

Commit 8dbd1b7

Browse files
simonfaltumTanishqDatabricks
authored andcommitted
auth: keep keyring backend on probe timeout during login (databricks#5210)
## Why When `databricks auth login` runs with secure storage and the OS keyring is reachable but locked, the probe triggers the GUI unlock prompt and immediately races a 3 second timeout. If the user takes longer than 3 seconds to type their password, the probe returns a TimeoutError and the entire login commits to plaintext, even though the user goes on to successfully unlock the keyring while OAuth is running in the browser. End result: user types keyring password, finishes OAuth, token still lands in plaintext. Confusing and easy to miss. ## Changes Before: any probe error (timeout or otherwise) caused secure-default logins to silently fall back to plaintext, and explicit-secure logins to error. Now: probe errors split into two cases. - `*TimeoutError`: keyring is reachable but locked. Login stays on keyring regardless of whether secure was explicit. The unlock prompt continues in parallel with OAuth, and by the time the SDK calls Store at the end the keyring has been unlocked. - Any other error: keyring is genuinely unavailable. Existing behavior unchanged (silent plaintext fallback when not explicit, error when explicit). The probe still triggers the unlock prompt up front, so the prompt is visible while the user is also doing OAuth. That overlap is the whole point: it gives the user time to unlock without the CLI giving up. ## Trade-off If the user does not unlock the keyring within the probe's 3 second window, login proceeds with the keyring backend on the assumption that the user will type their password during the OAuth ceremony. If instead they cancel the dialog, ignore it, or otherwise never enter the password, the SDK's final Store at the end of login triggers a second unlock attempt and times out after 3 seconds, failing the login. In other words: cooperative users who actually want to use the keyring see one prompt and succeed. Users who chose not to use the keyring after seeing it see a second prompt and a 3 second wait at the end. We accept this compromise on the assumption that users who hit the dialog at all are lawful-good users working with the CLI, not against it. ## Test plan - [x] New unit tests in `cache_test.go` cover the TimeoutError path: bare and wrapped, default and explicit secure (4 sub cases) - [x] Existing probe-fail tests still pass (plain errors still fall back / error as before) - [x] `task checks` clean - [x] `task lint-q` clean - [ ] Manual on Ubuntu: lock the default secret service collection, run `databricks auth login` with secure storage, deliberately wait >10s before entering the keyring password, confirm the token lands in keyring (not in the plaintext file cache) - [ ] Manual on Ubuntu: same setup but cancel the dialog after the probe times out, confirm login fails after a second 3 second wait at the end of OAuth (the documented trade-off)
1 parent c5ead36 commit 8dbd1b7

5 files changed

Lines changed: 66 additions & 17 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### CLI
66

7+
* `auth login` no longer falls back to plaintext when the OS keyring is reachable but locked. The unlock prompt shown by the probe now runs in parallel with the OAuth flow, and the token is stored in the keyring once the user has typed their password.
78
* `databricks auth describe` now reports where U2M (`databricks-cli`) tokens are stored: `plaintext` (`~/.databricks/token-cache.json`) or `secure` (OS keyring), and the source of the choice (env var, config setting, or default).
89
* Marked the default profile in the interactive pickers shown by `databricks auth switch`, `databricks auth logout`, `databricks auth token`, and `databricks auth login`, and moved it to the top of the list. `databricks auth login` and `databricks auth logout` now offer the same selectors as `databricks auth token` and `databricks auth switch` respectively.
910
* The interactive auth profile pickers now start in search mode so typing immediately filters the list, and the action entries (`+ Create a new profile`, `→ Enter a host URL manually`) are visually distinct from real profiles and stay visible regardless of the search query.

cmd/auth/login.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,10 @@ a new profile is created.
144144
ctx := cmd.Context()
145145
profileName := cmd.Flag("profile").Value.String()
146146

147-
// Resolve the cache before the browser step so a missing/locked keyring
148-
// surfaces here rather than after the user completes OAuth. When secure
149-
// is selected but the keyring is unreachable, this silently falls back
150-
// to plaintext and persists auth_storage = plaintext for next time.
147+
// Resolve the cache before the browser step so an unavailable
148+
// keyring surfaces here rather than after OAuth. The probe also
149+
// triggers the OS unlock prompt, which the user can answer during
150+
// OAuth.
151151
tokenCache, mode, err := storage.ResolveCacheForLogin(ctx, "")
152152
if err != nil {
153153
return err

libs/auth/storage/cache.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package storage
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67

78
"github.com/databricks/cli/libs/databrickscfg"
@@ -54,11 +55,13 @@ func ResolveCache(ctx context.Context, override StorageMode) (cache.TokenCache,
5455
// 2. When the user explicitly asked for secure (override, env var, or
5556
// config) but the keyring is unreachable, return an error. An explicit
5657
// "I want secure" is honored strictly: never silently downgrade.
58+
// 3. When the probe times out, stay on keyring regardless of explicit.
59+
// The timeout is ambiguous (locked vs hung); a misdiagnosis fails
60+
// the final Store rather than silently downgrading to plaintext.
5761
//
58-
// Both rules are dormant today: the resolver default is plaintext, so
62+
// Rules 1 and 2 are dormant today: the resolver default is plaintext, so
5963
// (mode=Secure, explicit=false) is unreachable. They activate when the
60-
// default flips to secure (MS4 / cli-ga). Wiring lands now so MS4 is a
61-
// single-line default flip plus pin-on-success additions.
64+
// default flips to secure at GA.
6265
//
6366
// Login-specific. Read paths (auth token, bundle commands) keep the original
6467
// keyring error so they don't silently mint plaintext copies of tokens that
@@ -120,7 +123,7 @@ func resolveCacheForLoginWith(ctx context.Context, override StorageMode, f cache
120123
//
121124
// Pin-on-success across modes (locking in the first working behavior to
122125
// insulate users from keyring flakiness) is intentionally not implemented
123-
// here. It lands with MS4 alongside the default flip; pinning before the
126+
// here. It lands at GA alongside the default flip; pinning before the
124127
// flip would freeze every default user into plaintext and make the flip a
125128
// no-op for them.
126129
func applyLoginFallback(ctx context.Context, mode StorageMode, explicit bool, f cacheFactories) (cache.TokenCache, StorageMode, error) {
@@ -133,6 +136,15 @@ func applyLoginFallback(ctx context.Context, mode StorageMode, explicit bool, f
133136
return c, mode, nil
134137
case StorageModeSecure:
135138
if probeErr := f.probeKeyring(); probeErr != nil {
139+
// Stay on keyring on timeout: a locked keyring being unlocked
140+
// during OAuth is the common case, and a misdiagnosed hang
141+
// fails the final Store anyway, which is better than a
142+
// silent plaintext downgrade.
143+
var timeoutErr *TimeoutError
144+
if errors.As(probeErr, &timeoutErr) {
145+
log.Debugf(ctx, "keyring probe timed out (%v); staying on keyring", probeErr)
146+
return f.newKeyring(), mode, nil
147+
}
136148
if explicit {
137149
return nil, "", fmt.Errorf("secure storage was requested but the OS keyring is not reachable: %w", probeErr)
138150
}
@@ -159,7 +171,7 @@ func applyLoginFallback(ctx context.Context, mode StorageMode, explicit bool, f
159171
// Only called on the (mode=Secure, explicit=false) probe-failure branch. That
160172
// branch is unreachable today (resolver default is plaintext), so this is
161173
// dormant infrastructure: it activates when the default flips to secure
162-
// (MS4) and lets default-on-broken-keyring users avoid a 3s probe on every
174+
// at GA and lets default-on-broken-keyring users avoid a 3s probe on every
163175
// command.
164176
func persistPlaintextFallback(ctx context.Context) error {
165177
configPath := env.Get(ctx, "DATABRICKS_CONFIG_FILE")

libs/auth/storage/cache_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package storage
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"os"
78
"path/filepath"
89
"testing"
@@ -232,6 +233,45 @@ func TestApplyLoginFallback_ExplicitSecure_ProbeFail_Errors(t *testing.T) {
232233
assert.Equal(t, "", persisted, "explicit-secure error must not write config")
233234
}
234235

236+
// A locked keyring with a slow user surfaces as TimeoutError. We want login
237+
// to stay on the keyring so the final Store lands there once the user has
238+
// finished unlocking, regardless of whether secure was explicit. Cover both
239+
// the bare TimeoutError (in case probe wraps thinner in the future) and the
240+
// real wrapped form returned by probeWithBackend.
241+
func TestApplyLoginFallback_ProbeTimeout_StaysOnKeyring(t *testing.T) {
242+
cases := []struct {
243+
name string
244+
explicit bool
245+
probeErr error
246+
}{
247+
{"default-secure, bare TimeoutError", false, &TimeoutError{Op: "set"}},
248+
{"default-secure, wrapped TimeoutError", false, fmt.Errorf("write: %w", &TimeoutError{Op: "set"})},
249+
{"explicit-secure, bare TimeoutError", true, &TimeoutError{Op: "set"}},
250+
{"explicit-secure, wrapped TimeoutError", true, fmt.Errorf("write: %w", &TimeoutError{Op: "set"})},
251+
}
252+
253+
for _, tc := range cases {
254+
t.Run(tc.name, func(t *testing.T) {
255+
hermetic(t)
256+
ctx := t.Context()
257+
configPath := env.Get(ctx, "DATABRICKS_CONFIG_FILE")
258+
259+
f := fakeFactories(t)
260+
f.probeKeyring = func() error { return tc.probeErr }
261+
262+
got, mode, err := applyLoginFallback(ctx, StorageModeSecure, tc.explicit, f)
263+
264+
require.NoError(t, err)
265+
assert.Equal(t, StorageModeSecure, mode)
266+
assert.Equal(t, "keyring", got.(stubCache).source)
267+
268+
persisted, gerr := databrickscfg.GetConfiguredAuthStorage(ctx, configPath)
269+
require.NoError(t, gerr)
270+
assert.Equal(t, "", persisted, "probe timeout must not persist plaintext fallback")
271+
})
272+
}
273+
}
274+
235275
func TestWrapForOAuthArgument(t *testing.T) {
236276
const (
237277
host = "https://example.com"

libs/auth/storage/keyring.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,10 @@ func NewKeyringCache() cache.TokenCache {
8888
}
8989
}
9090

91-
// ProbeKeyring returns nil if the OS keyring is reachable and accepts a
92-
// write+delete cycle within the standard timeout. A non-nil error means the
93-
// keyring cannot be used in this environment (no backend, headless Linux
94-
// session waiting on a UI prompt, locked keychain refusing access, etc.).
95-
//
96-
// Used by databricks auth login to decide whether to silently fall back to
97-
// plaintext storage before opening the browser, so the user does not
98-
// complete an OAuth flow only to fail at the final Store call.
91+
// ProbeKeyring returns nil if the OS keyring accepted a write+delete
92+
// cycle within the standard timeout. *TimeoutError means the keyring
93+
// was unresponsive (locked or hung, indistinguishable here); other
94+
// errors are definitive failures.
9995
func ProbeKeyring() error {
10096
return probeWithBackend(zalandoBackend{}, defaultKeyringTimeout)
10197
}

0 commit comments

Comments
 (0)