Skip to content
Closed
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
3e024a5
fix(oauth): store keyring tokens as one entry per provider
euxaristia Jul 14, 2026
1e15a4b
fix(oauth): lock keyring reads against concurrent Save/Delete
euxaristia Jul 14, 2026
ec252e3
fix(oauth): make the keyring token store bounded, recoverable, and mi…
euxaristia Jul 15, 2026
bb48e7a
test(oauth): assert Status also stays lock-free behind a crashed writ…
euxaristia Jul 15, 2026
7017b2f
fix(oauth): recover legacy tokens across an interrupted keyring migra…
euxaristia Jul 17, 2026
98ff17b
fix(oauth): lease the keyring lock with wall-clock time
euxaristia Jul 17, 2026
95faae0
fix(oauth): bound the keyring index chunk count before reading
euxaristia Jul 17, 2026
1cc9a11
fix(oauth): scope the keyring fallback lock to a per-user path
euxaristia Jul 17, 2026
46f858e
fix(oauth): fail logout on legacy-blob delete failure; cap index chun…
euxaristia Jul 18, 2026
9e12e6d
fix(oauth): use wall clock for lock timeout and bound keyring index
euxaristia Jul 19, 2026
64707c5
fix(oauth): refuse keyring indexes over the reader key cap on write
euxaristia Jul 19, 2026
a418471
fix(oauth): derive the keyring lock path from keyring identity, not f…
euxaristia Jul 22, 2026
3a0b470
fix(oauth): refuse to delete the legacy keyring blob on a transient r…
euxaristia Jul 22, 2026
e3d7868
fix(oauth): dedupe and validate the keyring index before fanning out …
euxaristia Jul 22, 2026
163dd91
fix(oauth): anchor the keyring lock on home dir and honor the legacy …
euxaristia Jul 22, 2026
d778466
fix(oauth): preserve token scopes across refresh and encode keyring l…
euxaristia Jul 23, 2026
c817cf6
fix(oauth): address review findings on legacy freshness, unindexed ke…
euxaristia Jul 24, 2026
a99ff78
fix(oauth): fix readKeyIndex chunked path rawKeys rename and cap order
euxaristia Jul 29, 2026
20d077e
test(oauth): cover read() index/entry desync recovery for chunked ind…
euxaristia Jul 30, 2026
5e91dc1
chore: force CodeRabbit re-review
euxaristia Jul 30, 2026
b011d7b
fix(oauth): address PR requested changes for keyring per-provider ent…
euxaristia Jul 31, 2026
45bba7b
fix(oauth): address review findings for per-provider keyring entries
euxaristia Jul 31, 2026
26ffa43
fix(oauth): address remaining keyring migration P1s
euxaristia Aug 1, 2026
0cf4072
fix(oauth): harden keyring index write and migration safety
euxaristia Aug 1, 2026
fe87d04
fix(oauth): freeze legacy keyring and tombstone durable deletes
euxaristia Aug 1, 2026
f095ac9
Harden OAuth migration state transitions against interrupted writes.
euxaristia Aug 2, 2026
8c365a8
fix(oauth): close lease panics, ownership refresh, and review findings.
euxaristia Aug 7, 2026
b7d850a
fix(oauth): preserve incomplete index chunks and tighten lease owners…
euxaristia Aug 7, 2026
c5cf244
fix(oauth): fence lost leases, protect missing index chunks, and boun…
euxaristia Aug 11, 2026
d7b3cd3
fix(oauth): anchor the Windows fallback keyring lock to the caller's …
euxaristia Aug 13, 2026
496ee39
fix(oauth): fence keyring writes at every step, not just around the c…
euxaristia Aug 13, 2026
913a970
fix(oauth): honor a logout performed by a running pre-migration binary
euxaristia Aug 13, 2026
95fe65c
fix(oauth): preflight index chunk capacity before writing any chunk
euxaristia Aug 13, 2026
6ae0da9
fix(oauth): stage index chunk growth under a new generation before pu…
euxaristia Aug 13, 2026
3627902
Address review feedback for keyring OAuth per-provider entries.
euxaristia Aug 14, 2026
4505f8d
fix(oauth): close remaining CodeRabbit findings on keyring store
euxaristia Aug 14, 2026
735d5b4
fix(oauth): address review feedback on keyring per-provider entries
euxaristia Aug 14, 2026
5b9457e
Address reviewer feedback on durable logout, legacy origin lifecycle,…
euxaristia Aug 14, 2026
a667b3a
Freeze the legacy keyring entry on every write path.
euxaristia Aug 15, 2026
3bc5493
fix(oauth): propagate lock cleanup errors and discard staged chunks o…
euxaristia Aug 15, 2026
e9fe983
fix(oauth): verify lock release token and reconcile legacy origin on …
euxaristia Aug 15, 2026
fccb2a4
fix(oauth): extract shared marker set helpers and overwrite stale in-…
euxaristia Aug 15, 2026
8ef5d53
fix(oauth): preserve indexed tokens on transient legacy read failure
euxaristia Aug 16, 2026
ff24df1
Keep legacyOrigin on token refresh so old-binary logout stays visible.
euxaristia Aug 16, 2026
d6acef2
Finish the mutationIntent contract across every token persist path.
euxaristia Aug 16, 2026
82e4c17
Document the keyring persist contract and lock MCP refresh to refresh…
euxaristia Aug 18, 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
63 changes: 63 additions & 0 deletions internal/oauth/flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,66 @@ 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)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestRefreshFailsOnHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"token expired"}`))
}))
defer server.Close()
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
_, err := Refresh(context.Background(), server.Client(), cfg, Token{RefreshToken: "expired-rt"}, nil)
if err == nil {
t.Fatal("expected refresh failure on HTTP 400")
}
}

func TestRefreshRequiresRefreshToken(t *testing.T) {
cfg := Config{ClientID: "c", TokenEndpoint: "https://example.com/token"}
_, err := Refresh(context.Background(), nil, cfg, Token{AccessToken: "only-access-token"}, nil)
if err == nil {
t.Fatal("expected error when token has no refresh token")
}
}
139 changes: 97 additions & 42 deletions internal/oauth/lock.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package oauth

import (
"errors"
"fmt"
"os"
"path/filepath"
Expand All @@ -11,27 +10,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() error, 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 @@ -40,57 +59,93 @@ func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
// other processes. Fail closed: remove the file and surface the error.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = lockutil.RemoveLockFile(lockPath)
return nil, fmt.Errorf("oauth: write token lock: %w", werr)
if rerr := lockutil.RemoveLockFile(lockPath); rerr != nil {
return nil, "", fmt.Errorf("oauth: write token lock: %w; cleanup: %v", werr, rerr)
}
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)
if rerr := lockutil.RemoveLockFile(lockPath); rerr != nil {
return nil, "", fmt.Errorf("oauth: close token lock: %w; cleanup: %v", cerr, rerr)
}
return nil, "", fmt.Errorf("oauth: close token lock: %w", cerr)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
var released bool
return func() {
return func() error {
if released {
return
return nil
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = lockutil.RemoveLockFile(lockPath)
if err := lockutil.RemoveLockFile(lockPath); err != nil {
return fmt.Errorf("oauth: release token lock %s: %w", filepath.Base(lockPath), err)
}
}
}, nil
return nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}, 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)
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. The
// platform predicate recognizes that as contention (and returns
// unrelated permission errors immediately) instead of failing the lock
// spuriously under concurrency.
if !isLockCreateContention(err) {
return nil, "", fmt.Errorf("oauth: acquire token lock: %w", err)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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
}
41 changes: 41 additions & 0 deletions internal/oauth/lock_owner_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//go:build !windows

package oauth

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

// isLockCreateContention reports whether a failed O_EXCL lock create should be
// treated as contention (another holder's lock exists) rather than a hard
// error. On non-Windows platforms the only contention errno is EEXIST
// (os.ErrExist); EACCES (os.ErrPermission) is a genuine permission failure and
// must surface immediately rather than spin to the lock timeout.
func isLockCreateContention(err error) bool {
return errors.Is(err, os.ErrExist)
}

// 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 errors.New("oauth lock fallback directory ownership metadata unavailable")
}
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// identityLockRoot is never called on non-Windows: keyringFallbackLockDir
// resolves the uid-anchored home and the fixed /tmp root directly. It exists so
// the shared fallback can name one helper without a build-tagged call site.
func identityLockRoot() (string, error) {
return "", fmt.Errorf("oauth: identityLockRoot is windows-only")
}
54 changes: 54 additions & 0 deletions internal/oauth/lock_owner_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//go:build windows

package oauth

import (
"errors"
"fmt"
"os"
"path/filepath"

"golang.org/x/sys/windows"
)

// isLockCreateContention reports whether a failed O_EXCL lock create should be
// treated as contention (another holder's lock exists) rather than a hard
// error. On Windows, a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so the O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than os.ErrExist; both are
// contention here.
func isLockCreateContention(err error) bool {
return errors.Is(err, os.ErrExist) || errors.Is(err, os.ErrPermission)
}

// checkOAuthLockDirOwner is a no-op on Windows: identityLockRoot resolves a
// per-user location from the process token rather than a shared root, so there
// is no co-tenant directory to validate ownership of.
func checkOAuthLockDirOwner(os.FileInfo) error {
return nil
}

// identityLockRoot resolves the last-resort keyring lock directory from the
// caller's own identity instead of the environment. os.TempDir() is not usable
// here for the same reason the Unix branch refuses it: it resolves %TMP%, then
// %TEMP%, then %USERPROFILE%, and the first two are launcher-controlled, so two
// processes of one user can compute different lock paths while writing the same
// fixed keyring account and race it. Being per-user by default is not the
// property that matters; being stable for that user is.
//
// SHGetKnownFolderPath reads the user's profile location through the process
// token, so it ignores %LOCALAPPDATA% and every other temp override. Failing
// closed is deliberate: without a stable identity there is no lock path two
// processes are guaranteed to agree on, and a guessed one silently reintroduces
// the race it is meant to prevent.
func identityLockRoot() (string, error) {
base, err := windows.KnownFolderPath(windows.FOLDERID_LocalAppData, 0)
if err != nil {
return "", fmt.Errorf("resolve LocalAppData for keyring lock: %w", err)
}
dir := filepath.Join(base, "zero", "oauth-locks")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
return dir, nil
}
Loading
Loading