Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 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
6ea4827
fix(oauth): fix readKeyIndex chunked path rawKeys rename and cap order
euxaristia Jul 29, 2026
2270931
test(oauth): cover read() index/entry desync recovery for chunked ind…
euxaristia Jul 30, 2026
61b8406
chore: force CodeRabbit re-review
euxaristia Jul 30, 2026
5c4b8b6
fix(oauth): address PR requested changes for keyring per-provider ent…
euxaristia Jul 31, 2026
c511460
fix(oauth): address review findings for per-provider keyring entries
euxaristia Jul 31, 2026
69c8587
fix(oauth): address remaining keyring migration P1s
euxaristia Aug 1, 2026
7d3a429
fix(oauth): harden keyring index write and migration safety
euxaristia Aug 1, 2026
b112dc2
fix(oauth): freeze legacy keyring and tombstone durable deletes
euxaristia Aug 1, 2026
ad2032f
Harden OAuth migration state transitions against interrupted writes.
euxaristia Aug 2, 2026
797c6ff
fix(oauth): close lease panics, ownership refresh, and review findings.
euxaristia Aug 7, 2026
67a5ad5
fix(oauth): preserve incomplete index chunks and tighten lease owners…
euxaristia Aug 7, 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
17 changes: 11 additions & 6 deletions internal/oauth/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,20 +167,25 @@ func Refresh(ctx context.Context, client *http.Client, cfg Config, current Token
if trimmed(cfg.TokenEndpoint) == "" {
return Token{}, errors.New("oauth: no token endpoint configured for refresh")
}
// Prefer the scopes the current token was issued with; fall back to the
// configured defaults only when the stored token has none. The same set is
// sent on the wire and kept as the base so a response that omits scope
// cannot report a different grant than the provider just processed.
scopes := current.Scopes
if len(scopes) == 0 {
scopes = cfg.Scopes
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", refresh)
form.Set("client_id", cfg.ClientID)
if secret := trimmed(cfg.ClientSecret); secret != "" {
form.Set("client_secret", secret)
}
if len(cfg.Scopes) > 0 {
form.Set("scope", strings.Join(cfg.Scopes, " "))
if len(scopes) > 0 {
form.Set("scope", strings.Join(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}
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
42 changes: 42 additions & 0 deletions internal/oauth/flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,45 @@ func TestRefreshPreservesTokenTypeWhenOmitted(t *testing.T) {
t.Fatalf("refresh should carry the existing token_type forward, got %q", tok.TokenType)
}
}

func TestRefreshPreservesScopesWhenOmitted(t *testing.T) {
var gotScope string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotScope = r.FormValue("scope")
_, _ = 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 gotScope != "custom-scope" {
t.Fatalf("refresh form scope = %q, want current token scopes", gotScope)
}
if len(tok.Scopes) != 1 || tok.Scopes[0] != "custom-scope" {
t.Fatalf("refresh should carry existing scopes forward, got %v", tok.Scopes)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestRefreshUsesConfigScopesWhenTokenHasNone(t *testing.T) {
var gotScope string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotScope = r.FormValue("scope")
_, _ = w.Write([]byte(`{"access_token":"new-at","expires_in":3600}`))
}))
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"}, nil)
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if gotScope != "fallback-scope" {
t.Fatalf("refresh form scope = %q, want cfg.Scopes fallback", gotScope)
}
if len(tok.Scopes) != 1 || tok.Scopes[0] != "fallback-scope" {
t.Fatalf("refresh should use cfg scopes when token has none, got %v", tok.Scopes)
}
}
112 changes: 80 additions & 32 deletions internal/oauth/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,47 @@ import (
"github.com/Gitlawb/zero/internal/lockutil"
)

const (
fileLockTimeout = 5 * time.Second
// Lock timing knobs (vars so tests can shorten absolute ceilings without
// changing production defaults).
var (
// fileLockTimeout is how long acquisition waits after the last sign of a
// healthy holder (or when the lock path cannot be stated). A multi-entry
// keyring pass can legitimately run several 10s OS commands while refreshing
// the lease; contenders must not give up while that lease stays healthy.
// While the holder's mtime stays within fileLockStaleAfter, this idle
// deadline is extended so a fixed 5s window cannot fail a healthy peer.
fileLockTimeout = 5 * time.Second
// fileLockStaleAfter is how old a lock file's mtime must be before a waiter
// may reclaim it as abandoned. Must stay above one keyring command timeout
// plus lease refresh slack (holders refresh every fileLockRefreshInterval).
fileLockStaleAfter = 30 * time.Second
)

var lockSeq atomic.Uint64

// acquireFileLock takes a cross-process exclusive lock by creating lockPath with
// O_EXCL. It retries with a short backoff until a timeout, breaking a lock whose
// 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.
func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
// O_EXCL. It retries with a short backoff while a live holder's lease remains
// healthy (mtime refreshed within fileLockStaleAfter), reclaiming only a lock
// older than that threshold 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 returned token is the contents written into the lock file; lease refresh
// must re-check it before touching mtime so a replaced holder cannot keep a
// thief's lock alive.
//
// Timing always uses 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). Measuring the deadline with that clock would either never
// fire (fixed clock) or diverge from the mtime lease stamps (wall-clock).
func acquireFileLock(lockPath string, now func() time.Time) (unlock func(), token string, err error) {
if now == nil {
now = time.Now
}
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
return nil, "", err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1))
deadline := now().Add(fileLockTimeout)
token = fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1))
idleDeadline := time.Now().Add(fileLockTimeout)
for {
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
Expand All @@ -41,11 +61,11 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: write token lock: %w", werr)
return nil, "", fmt.Errorf("oauth: write token lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: close token lock: %w", cerr)
return nil, "", fmt.Errorf("oauth: close token lock: %w", cerr)
}
var released bool
return func() {
Expand All @@ -56,41 +76,69 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
}
}, nil
}, token, nil
}
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry, exactly like ErrExist — otherwise the lock
// spuriously fails under concurrency on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("oauth: acquire token lock: %w", err)
return nil, "", fmt.Errorf("oauth: acquire token lock: %w", err)
}
// Reclaim a stale lock left by a crashed holder — atomically (H3). A blind
// Remove lets two racers both reclaim + recreate and so both hold the lock;
// reclaimStaleLock renames the file aside (only one rename wins) and restores
// it if it turns out fresh, so a live lock is never deleted out from under it.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > fileLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
return err == nil && time.Since(info.ModTime()) <= fileLockStaleAfter
})
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, fmt.Errorf("oauth: reclaim stale token lock: %w", rerr)
}
if cleared {
continue
if info, statErr := os.Stat(lockPath); statErr == nil {
age := time.Since(info.ModTime())
// Future mtimes (clock skew, hostile Chtimes) are not healthy leases:
// age is negative, so neither the reclaim branch nor the deadline
// extension below treats them as live. Contenders time out instead of
// waiting forever on a never-stale lock.
if age > fileLockStaleAfter {
cleared, rerr := lockutil.ReclaimStaleLock(lockPath, token, func(reclaimedPath string) bool {
info, err := os.Stat(reclaimedPath)
if err != nil {
return false
}
reclaimedAge := time.Since(info.ModTime())
return reclaimedAge >= 0 && reclaimedAge <= fileLockStaleAfter
})
Comment thread
euxaristia marked this conversation as resolved.
if rerr != nil {
// Reclaim hit a hard failure: the rename aside failed outright, or a
// live holder's lock could not be put back (the lock path may be
// missing, so re-acquiring would break mutual exclusion). Fail closed
// instead of spinning to the deadline.
return nil, "", fmt.Errorf("oauth: reclaim stale token lock: %w", rerr)
}
if cleared {
continue
}
// Lost the reclaim race, or isLive reported a still-fresh holder
// (callback true → ReclaimStaleLock restores and returns false).
// Refresh the idle deadline so reclaim work that overran the prior
// window does not immediately time out a healthy peer.
idleDeadline = time.Now().Add(fileLockTimeout)
} else if age >= 0 {
// Holder looks healthy (lease refreshed recently, mtime not in the
// future). Keep waiting for the critical section to finish rather
// than timing out after a fixed window shorter than a legitimate
// multi-entry keyring pass.
idleDeadline = time.Now().Add(fileLockTimeout)
}
// 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) {
return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath))
if time.Now().After(idleDeadline) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return nil, "", fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath))
}
time.Sleep(10 * time.Millisecond)
}
}

// ownLockFile reports whether path still holds token. Used by lease refresh so
// a holder that was reclaimed after a long pause cannot Chtimes a replacement
// lock and keep two critical sections alive.
func ownLockFile(path, token string) bool {
data, err := os.ReadFile(path)
return err == nil && string(data) == token
}
24 changes: 24 additions & 0 deletions internal/oauth/lock_owner_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//go:build !windows

package oauth

import (
"fmt"
"os"
"syscall"
)

// checkOAuthLockDirOwner rejects a fallback lock directory not owned by the
// current user: on a shared temp root another user could have pre-created the
// path and would then control its lifetime (deletion/renaming), permanently
// denying OAuth keyring operations.
func checkOAuthLockDirOwner(info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return nil
}
if int(stat.Uid) != os.Geteuid() {
return fmt.Errorf("oauth lock fallback directory is owned by uid %d, not the current user", stat.Uid)
}
return nil
}
11 changes: 11 additions & 0 deletions internal/oauth/lock_owner_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//go:build windows

package oauth

import "os"

// checkOAuthLockDirOwner is a no-op on Windows: the process temp directory is
// per-user by default, and keyringFallbackLockDir returns it directly.
func checkOAuthLockDirOwner(os.FileInfo) error {
return nil
}
Loading
Loading