Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a0cf253
fix(oauth): store keyring tokens as one entry per provider
euxaristia Jul 14, 2026
5bfbd16
fix(oauth): lock keyring reads against concurrent Save/Delete
euxaristia Jul 14, 2026
f2e37bd
fix(oauth): make the keyring token store bounded, recoverable, and mi…
euxaristia Jul 15, 2026
44aff1a
test(oauth): assert Status also stays lock-free behind a crashed writ…
euxaristia Jul 15, 2026
2a27743
fix(oauth): recover legacy tokens across an interrupted keyring migra…
euxaristia Jul 17, 2026
527846e
fix(oauth): lease the keyring lock with wall-clock time
euxaristia Jul 17, 2026
cedc33e
fix(oauth): bound the keyring index chunk count before reading
euxaristia Jul 17, 2026
08ed2c6
fix(oauth): scope the keyring fallback lock to a per-user path
euxaristia Jul 17, 2026
73f6da8
fix(oauth): fail logout on legacy-blob delete failure; cap index chun…
euxaristia Jul 18, 2026
ecca0c2
fix(oauth): use wall clock for lock timeout and bound keyring index
euxaristia Jul 19, 2026
19a14ae
fix(oauth): refuse keyring indexes over the reader key cap on write
euxaristia Jul 19, 2026
1895276
fix(oauth): derive the keyring lock path from keyring identity, not f…
euxaristia Jul 22, 2026
1d6a8bf
fix(oauth): refuse to delete the legacy keyring blob on a transient r…
euxaristia Jul 22, 2026
c07b9dc
fix(oauth): dedupe and validate the keyring index before fanning out …
euxaristia Jul 22, 2026
9e3f52c
fix(oauth): anchor the keyring lock on home dir and honor the legacy …
euxaristia Jul 22, 2026
226852b
fix(oauth): preserve token scopes across refresh and encode keyring l…
euxaristia Jul 23, 2026
ee4163d
fix(oauth): address review findings on legacy freshness, unindexed ke…
euxaristia Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions internal/oauth/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,11 @@ func Refresh(ctx context.Context, client *http.Client, cfg Config, current Token
if len(cfg.Scopes) > 0 {
form.Set("scope", strings.Join(cfg.Scopes, " "))
}
// Carry the existing token_type forward: a refresh response commonly omits it,
// and PostToken only overwrites TokenType when the response supplies one, so
// without seeding it here the type would be silently lost across refreshes (L15).
base := Token{Scopes: current.Scopes, RefreshToken: refresh, Account: current.Account, IDToken: current.IDToken, TokenType: current.TokenType}
scopes := current.Scopes
if len(scopes) == 0 {
scopes = cfg.Scopes
}
base := Token{Scopes: scopes, RefreshToken: refresh, Account: current.Account, IDToken: current.IDToken, TokenType: current.TokenType}
return PostToken(ctx, client, cfg.TokenEndpoint, form, base, now)
}

Expand Down
15 changes: 15 additions & 0 deletions internal/oauth/flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,18 @@ func TestRefreshPreservesTokenTypeWhenOmitted(t *testing.T) {
t.Fatalf("refresh should carry the existing token_type forward, got %q", tok.TokenType)
}
}

func TestRefreshPreservesScopesWhenOmitted(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"access_token":"new-at","expires_in":3600}`)) // no scope in response
}))
defer server.Close()
cfg := Config{ClientID: "c", TokenEndpoint: server.URL, Scopes: []string{"fallback-scope"}}
tok, err := Refresh(context.Background(), server.Client(), cfg, Token{RefreshToken: "keep-me", Scopes: []string{"custom-scope"}}, nil)
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if len(tok.Scopes) != 1 || tok.Scopes[0] != "custom-scope" {
t.Fatalf("refresh should carry existing scopes forward, got %v", tok.Scopes)
}
}
10 changes: 8 additions & 2 deletions internal/oauth/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ var lockSeq atomic.Uint64
// file is older than fileLockStaleAfter (so a crashed holder cannot deadlock the
// store). Release is ownership-aware: it removes the lock only if it still holds
// our token, so a stale-broken holder cannot delete a newer holder's lock.
//
// The acquisition deadline is always measured against the real wall clock, never
// the now parameter: now is StoreOptions.Now, which callers may legitimately fix
// (e.g. a test or an embedded clock), and deadline := now().Add(fileLockTimeout)
// followed by now().After(deadline) would then never become true, turning lock
// contention into an infinite retry loop instead of a timeout error.
func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if now == nil {
now = time.Now
Expand All @@ -31,7 +37,7 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1))
deadline := now().Add(fileLockTimeout)
deadline := time.Now().Add(fileLockTimeout)
for {
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
Expand Down Expand Up @@ -88,7 +94,7 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
// Lost the reclaim race (or it was actually fresh) — fall through to the
// bounded wait rather than hot-spinning on a reclaim that never wins.
}
if now().After(deadline) {
if time.Now().After(deadline) {
return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath))
}
time.Sleep(10 * time.Millisecond)
Expand Down
Loading
Loading