From bef3ccf4521d4ed431f3cdfc66e618c619ce28e4 Mon Sep 17 00:00:00 2001 From: "Simen A. W. Olsen" Date: Tue, 3 Nov 2026 22:08:49 +0100 Subject: Share one Fiken login between programs fikenconfig keeps an OAuth app and a grant per account in ~/.config/fiken, and finds the account that reaches a company. Renewing a grant takes a lock, so programs sharing it take turns. --- fikenauth/lock_other.go | 7 +++++++ fikenauth/lock_unix.go | 31 +++++++++++++++++++++++++++++++ fikenauth/login.go | 4 ++-- fikenauth/oauth.go | 8 ++++---- fikenauth/source.go | 17 +++++++++++++++++ fikenauth/source_test.go | 31 +++++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 fikenauth/lock_other.go create mode 100644 fikenauth/lock_unix.go (limited to 'fikenauth') diff --git a/fikenauth/lock_other.go b/fikenauth/lock_other.go new file mode 100644 index 0000000..b6c6075 --- /dev/null +++ b/fikenauth/lock_other.go @@ -0,0 +1,7 @@ +//go:build !unix + +package fikenauth + +// lock is a no-op where flock does not exist; two programs renewing one grant +// at the same moment can then still log each other out. +func (s *Store) lock() (unlock func(), err error) { return func() {}, nil } diff --git a/fikenauth/lock_unix.go b/fikenauth/lock_unix.go new file mode 100644 index 0000000..e29472d --- /dev/null +++ b/fikenauth/lock_unix.go @@ -0,0 +1,31 @@ +//go:build unix + +package fikenauth + +import ( + "fmt" + "os" + "path/filepath" + "syscall" +) + +// lock takes an exclusive lock beside the token file, held until unlock is +// called. It is advisory: it only orders programs that also take it, which is +// every program using this package. +func (s *Store) lock() (unlock func(), err error) { + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return nil, fmt.Errorf("create %s: %w", filepath.Dir(s.path), err) + } + f, err := os.OpenFile(s.path+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("lock %s: %w", s.path, err) + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + f.Close() + return nil, fmt.Errorf("lock %s: %w", s.path, err) + } + return func() { + syscall.Flock(int(f.Fd()), syscall.LOCK_UN) //nolint:errcheck // closing releases it anyway + f.Close() + }, nil +} diff --git a/fikenauth/login.go b/fikenauth/login.go index e438755..ac8eee9 100644 --- a/fikenauth/login.go +++ b/fikenauth/login.go @@ -52,11 +52,11 @@ func (c Config) Login(ctx context.Context, announce func(authURL string)) (Token redirect, err := url.Parse(c.redirectURL()) if err != nil { - return Token{}, fmt.Errorf("fiken.redirect_url %q is not a URL: %w", c.redirectURL(), err) + return Token{}, fmt.Errorf("redirect URL %q is not a URL: %w", c.redirectURL(), err) } if !isLoopback(redirect.Hostname()) { return Token{}, fmt.Errorf( - "fiken.redirect_url %q does not point at this machine; "+ + "redirect URL %q does not point at this machine; "+ "use a localhost URL, or log in with --manual", c.redirectURL()) } diff --git a/fikenauth/oauth.go b/fikenauth/oauth.go index f899ccb..79d90ea 100644 --- a/fikenauth/oauth.go +++ b/fikenauth/oauth.go @@ -65,19 +65,19 @@ func (e *Error) AccessDenied() bool { return e.Code == "access_denied" } func (c Config) Validate() error { var missing []string if c.ClientID == "" { - missing = append(missing, "fiken.client_id") + missing = append(missing, "client_id") } if c.ClientSecret == "" { - missing = append(missing, "fiken.client_secret_file (or FIKEN_CLIENT_SECRET)") + missing = append(missing, "client_secret") } if len(missing) > 0 { return fmt.Errorf( - "no Fiken OAuth app configured: set %s. Create the app in Fiken under "+ + "no Fiken OAuth app configured: missing %s. Create the app in Fiken under "+ "Brukerinnstillinger -> API, registering %s as the redirect URL", strings.Join(missing, " and "), c.redirectURL()) } if _, err := url.Parse(c.redirectURL()); err != nil { - return fmt.Errorf("fiken.redirect_url %q is not a URL: %w", c.redirectURL(), err) + return fmt.Errorf("redirect URL %q is not a URL: %w", c.redirectURL(), err) } return nil } diff --git a/fikenauth/source.go b/fikenauth/source.go index f6289c2..0a61479 100644 --- a/fikenauth/source.go +++ b/fikenauth/source.go @@ -52,6 +52,23 @@ func (s *Source) Token(ctx context.Context) (string, error) { if s.token.Valid(s.cfg.now()) { return s.token.AccessToken, nil } + + // Another program sharing the file may be renewing it right now. Fiken + // rotates the refresh token on every renewal, so renewing twice from one + // refresh token logs out whichever finishes second. The lock orders them, + // and reading again under it picks up what the first one saved. + unlock, err := s.store.lock() + if err != nil { + return "", err + } + defer unlock() + if s.token, err = s.store.Load(); err != nil { + s.loaded = false + return "", err + } + if s.token.Valid(s.cfg.now()) { + return s.token.AccessToken, nil + } if !s.token.Renewable() { return "", fmt.Errorf("the Fiken access token has expired and cannot be renewed: %w", ErrNoToken) } diff --git a/fikenauth/source_test.go b/fikenauth/source_test.go index 1c960d6..24147e0 100644 --- a/fikenauth/source_test.go +++ b/fikenauth/source_test.go @@ -170,3 +170,34 @@ func TestLogoutWithoutATokenIsNoToken(t *testing.T) { t.Errorf("err = %v, want ErrNoToken", err) } } + +// Two programs sharing one grant: the second to find it expired must use what +// the first saved, because refresh-1 died when the first one renewed it. +func TestSourceRereadsAGrantAnotherProgramRenewed(t *testing.T) { + server := newAuthServer(t) + server.refreshOut = `{"access_token":"access-2","refresh_token":"refresh-2","expires_in":3600}` + store := tempStore(t) + if err := store.Save(Token{ + AccessToken: "expired", RefreshToken: "refresh-1", + ExpiresAt: fixedNow.Add(-time.Hour), + }); err != nil { + t.Fatal(err) + } + + late := NewSource(server.config(), NewStore(store.Path())) + if _, err := late.Current(); err != nil { + t.Fatal(err) + } + if _, err := NewSource(server.config(), store).Token(t.Context()); err != nil { + t.Fatalf("first program: %v", err) + } + + server.gotForm = nil + got, err := late.Token(t.Context()) + if err != nil { + t.Fatalf("second program: %v", err) + } + if got != "access-2" || server.gotForm != nil { + t.Errorf("Token = %q, refreshed again: %v; want the first program's grant", got, server.gotForm != nil) + } +} -- cgit v1.3.1