aboutsummaryrefslogtreecommitdiffstats
path: root/fikenauth
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-11-03 22:08:49 +0100
committerSimen A. W. Olsen <hello@simenandre.no>2026-11-03 22:08:49 +0100
commitbef3ccf4521d4ed431f3cdfc66e618c619ce28e4 (patch)
treeef8e6ff90fed4b27d4f2cfd082106f2efdb38ca5 /fikenauth
parenta5491c9dccdf4678bf3b4380d208cf2562afeee4 (diff)
downloadfiken-main.tar.gz
fiken-main.zip
Share one Fiken login between programsHEADv0.4.0main
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.
Diffstat (limited to 'fikenauth')
-rw-r--r--fikenauth/lock_other.go7
-rw-r--r--fikenauth/lock_unix.go31
-rw-r--r--fikenauth/login.go4
-rw-r--r--fikenauth/oauth.go8
-rw-r--r--fikenauth/source.go17
-rw-r--r--fikenauth/source_test.go31
6 files changed, 92 insertions, 6 deletions
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)
+ }
+}