diff options
| author | Simen A. W. Olsen <hello@simenandre.no> | 2026-11-03 22:08:49 +0100 |
|---|---|---|
| committer | Simen A. W. Olsen <hello@simenandre.no> | 2026-11-03 22:08:49 +0100 |
| commit | bef3ccf4521d4ed431f3cdfc66e618c619ce28e4 (patch) | |
| tree | ef8e6ff90fed4b27d4f2cfd082106f2efdb38ca5 /fikenconfig | |
| parent | a5491c9dccdf4678bf3b4380d208cf2562afeee4 (diff) | |
| download | fiken-main.tar.gz fiken-main.zip | |
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 'fikenconfig')
| -rw-r--r-- | fikenconfig/config.go | 436 | ||||
| -rw-r--r-- | fikenconfig/config_test.go | 213 |
2 files changed, 649 insertions, 0 deletions
diff --git a/fikenconfig/config.go b/fikenconfig/config.go new file mode 100644 index 0000000..9b6b800 --- /dev/null +++ b/fikenconfig/config.go @@ -0,0 +1,436 @@ +// Package fikenconfig is the Fiken login shared by every program on a machine: +// one OAuth app, and one grant per Fiken account, each recording which +// companies it reaches. +// +// A program names the company it works for and is handed the account that +// reaches it, so logging in once, from any of them, logs in all of them. They +// read and renew the same grant file, which is the only safe way to share one: +// Fiken rotates the refresh token on every renewal, so two copies of a grant +// means the first to renew silently logs out the other. +package fikenconfig + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "simenandre.no/fiken/fiken" + "simenandre.no/fiken/fikenauth" +) + +// Where the configuration lives, and what in the environment overrides it. +const ( + DirEnv = "FIKEN_CONFIG_DIR" + ClientIDEnv = "FIKEN_CLIENT_ID" + ClientSecretEnv = "FIKEN_CLIENT_SECRET" +) + +const ( + configFile = "config.json" + accountsDir = "accounts" + maxConfigBytes = 1 << 20 +) + +// ErrNoAccount means no account logged in here reaches the company asked for. +var ErrNoAccount = errors.New("no Fiken account reaches that company") + +// Config is the shared configuration. It is written to disk, so the JSON field +// names are a file format every program using this package reads. +type Config struct { + App App `json:"app"` + Accounts map[string]*Account `json:"accounts,omitempty"` + + // LoginCommand is how the calling program logs in, named whenever logging + // in is the fix. + LoginCommand string `json:"-"` + + // ClientOptions and AuthOverride point the API and the OAuth endpoints + // somewhere else, which is how tests keep away from Fiken. + ClientOptions []fiken.Option `json:"-"` + AuthOverride func(*fikenauth.Config) `json:"-"` + Now func() time.Time `json:"-"` + + dir string +} + +// App is the OAuth app registered in Fiken. One serves every account: any +// Fiken user can grant it access to their companies. +type App struct { + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + RedirectURL string `json:"redirect_url,omitempty"` +} + +// Account is one Fiken login, keyed by its email in Config.Accounts. +type Account struct { + Name string `json:"name,omitempty"` + + // Companies is what the login reached when it was last asked. A company + // added in Fiken since is found by asking again (Refresh), which Credential + // does by itself when no account has the company it wants. + Companies []fiken.Company `json:"companies"` + + // Token is a personal API token. Without one the account is an OAuth grant, + // kept in its own file beside this one. + Token string `json:"token,omitempty"` +} + +// Reaches reports whether the account can act for the company. +func (a *Account) Reaches(slug string) bool { + for _, c := range a.Companies { + if c.Slug == slug { + return true + } + } + return false +} + +// Dir is where the shared configuration lives: $FIKEN_CONFIG_DIR, or fiken +// under $XDG_CONFIG_HOME, or ~/.config/fiken. Not os.UserConfigDir, which on a +// Mac is a directory nobody looks in for a file they might edit. +func Dir() (string, error) { + if dir := strings.TrimSpace(os.Getenv(DirEnv)); dir != "" { + return dir, nil + } + if base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); base != "" { + return filepath.Join(base, "fiken"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot tell where the Fiken configuration is: set %s", DirEnv) + } + return filepath.Join(home, ".config", "fiken"), nil +} + +// Open reads the configuration in dir, or in Dir when dir is empty. One that +// does not exist yet is empty, not an error: nobody has logged in. +func Open(dir string) (*Config, error) { + if dir == "" { + var err error + if dir, err = Dir(); err != nil { + return nil, err + } + } + c := &Config{dir: dir} + path := c.Path() + + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return c, nil + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if info.Size() > maxConfigBytes { + return nil, fmt.Errorf("%s is %d bytes, which is not a Fiken configuration", path, info.Size()) + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(raw, c); err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + return c, nil +} + +// Path is the configuration file. +func (c *Config) Path() string { return filepath.Join(c.dir, configFile) } + +// Save writes the configuration, readable only by its owner because it holds +// the app's secret and any personal tokens. It is written to a temporary name +// and renamed, so an interrupted write leaves the old one whole. +func (c *Config) Save() error { + if err := os.MkdirAll(c.dir, 0o700); err != nil { + return fmt.Errorf("create %s: %w", c.dir, err) + } + // Map keys are sorted by encoding/json, so the file is stable across saves. + raw, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", c.Path(), err) + } + temp, err := os.CreateTemp(c.dir, ".config-*") + if err != nil { + return fmt.Errorf("write %s: %w", c.Path(), err) + } + defer os.Remove(temp.Name()) //nolint:errcheck // already renamed on success + + if err := temp.Chmod(0o600); err != nil { + temp.Close() + return fmt.Errorf("write %s: %w", c.Path(), err) + } + if _, err := temp.Write(append(raw, '\n')); err != nil { + temp.Close() + return fmt.Errorf("write %s: %w", c.Path(), err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("write %s: %w", c.Path(), err) + } + if err := os.Rename(temp.Name(), c.Path()); err != nil { + return fmt.Errorf("write %s: %w", c.Path(), err) + } + return nil +} + +// Auth is the OAuth app, with $FIKEN_CLIENT_ID and $FIKEN_CLIENT_SECRET taking +// precedence over the file. +func (c *Config) Auth() fikenauth.Config { + cfg := fikenauth.Config{ + ClientID: pick(os.Getenv(ClientIDEnv), c.App.ClientID), + ClientSecret: pick(os.Getenv(ClientSecretEnv), c.App.ClientSecret), + RedirectURL: strings.TrimSpace(c.App.RedirectURL), + Now: c.Now, + } + if c.AuthOverride != nil { + c.AuthOverride(&cfg) + } + return cfg +} + +// CheckApp reports what is missing from the OAuth app, and where it goes. +func (c *Config) CheckApp() error { + if err := c.Auth().Validate(); err != nil { + return fmt.Errorf("%w. It goes under \"app\" in %s, or in $%s and $%s", + err, c.Path(), ClientIDEnv, ClientSecretEnv) + } + return nil +} + +// Source is the token source for an OAuth account's grant. +func (c *Config) Source(email string) (*fikenauth.Source, error) { + path, err := c.grantPath(email) + if err != nil { + return nil, err + } + source := fikenauth.NewSource(c.Auth(), fikenauth.NewStore(path)) + source.LoginCommand = c.LoginCommand + return source, nil +} + +func (c *Config) grantPath(email string) (string, error) { + if email == "" || email != filepath.Base(email) || strings.HasPrefix(email, ".") { + return "", fmt.Errorf("%q cannot name a Fiken account's file", email) + } + return filepath.Join(c.dir, accountsDir, email+".json"), nil +} + +func (c *Config) credential(email string) (fiken.TokenSource, error) { + if a := c.Accounts[email]; a != nil && a.Token != "" { + return fiken.StaticToken(a.Token), nil + } + return c.Source(email) +} + +// Reach is a company and the account that reaches it. +type Reach struct { + Company fiken.Company + Account string +} + +// Companies is every company some account reaches, by slug. A company two +// accounts reach is listed once, under the first account by email, which is +// the one Credential uses. +func (c *Config) Companies() []Reach { + var out []Reach + seen := map[string]bool{} + for _, email := range c.emails() { + for _, co := range c.Accounts[email].Companies { + if !seen[co.Slug] { + seen[co.Slug] = true + out = append(out, Reach{Company: co, Account: email}) + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Company.Slug < out[j].Company.Slug }) + return out +} + +func (c *Config) emails() []string { + emails := make([]string, 0, len(c.Accounts)) + for email := range c.Accounts { + emails = append(emails, email) + } + sort.Strings(emails) + return emails +} + +// AccountFor is the account that reaches company, by what each account +// reached when last asked. +func (c *Config) AccountFor(company string) (string, bool) { + for _, email := range c.emails() { + if c.Accounts[email].Reaches(company) { + return email, true + } + } + return "", false +} + +// Credential is the token source for an account reaching company. When none +// does, every account is asked again first, in case the company was added in +// Fiken since. +func (c *Config) Credential(ctx context.Context, company string) (fiken.TokenSource, error) { + email, ok := c.AccountFor(company) + if !ok && len(c.Accounts) > 0 { + if err := c.Refresh(ctx); err != nil { + return nil, err + } + email, ok = c.AccountFor(company) + } + if !ok { + return nil, c.noAccount(company) + } + return c.credential(email) +} + +// Client is a Fiken client acting as the account that reaches company. +func (c *Config) Client(ctx context.Context, company string) (*fiken.Client, error) { + cred, err := c.Credential(ctx, company) + if err != nil { + return nil, err + } + return fiken.NewWithTokens(cred, c.ClientOptions...) +} + +func (c *Config) noAccount(company string) error { + fix := "log in to Fiken as a user who can reach it" + if c.LoginCommand != "" { + fix = "run `" + c.LoginCommand + "` as a Fiken user who can reach it" + } + if len(c.Accounts) == 0 { + return fmt.Errorf("company %q: nobody is logged in to Fiken in %s; %s: %w", + company, c.dir, fix, ErrNoAccount) + } + var slugs []string + for _, r := range c.Companies() { + slugs = append(slugs, r.Company.Slug) + } + return fmt.Errorf("company %q: the accounts in %s reach %s; %s: %w", + company, c.dir, strings.Join(slugs, ", "), fix, ErrNoAccount) +} + +// Refresh asks every account which companies it reaches now, and saves. +func (c *Config) Refresh(ctx context.Context) error { + for _, email := range c.emails() { + cred, err := c.credential(email) + if err != nil { + return err + } + companies, err := c.companies(ctx, cred) + if err != nil { + return fmt.Errorf("Fiken account %s: %w", email, err) + } + c.Accounts[email].Companies = companies + } + return c.Save() +} + +func (c *Config) companies(ctx context.Context, cred fiken.TokenSource) ([]fiken.Company, error) { + client, err := fiken.NewWithTokens(cred, c.ClientOptions...) + if err != nil { + return nil, err + } + companies, err := client.Companies(ctx) + if err != nil { + return nil, err + } + sort.Slice(companies, func(i, j int) bool { return companies[i].Slug < companies[j].Slug }) + return companies, nil +} + +// AddGrant records the grant a login produced, under the email of the Fiken +// user who gave it, and returns that email. Logging in again as the same user +// replaces their grant. +func (c *Config) AddGrant(ctx context.Context, grant fikenauth.Token) (string, error) { + email, account, err := c.whoIs(ctx, fiken.StaticToken(grant.AccessToken)) + if err != nil { + return "", err + } + path, err := c.grantPath(email) + if err != nil { + return "", err + } + if err := fikenauth.NewStore(path).Save(grant); err != nil { + return "", err + } + return email, c.put(email, account) +} + +// AddToken records a personal API token, under the email of the Fiken user it +// belongs to, and returns that email. +func (c *Config) AddToken(ctx context.Context, token string) (string, error) { + token = strings.TrimSpace(token) + email, account, err := c.whoIs(ctx, fiken.StaticToken(token)) + if err != nil { + return "", err + } + account.Token = token + return email, c.put(email, account) +} + +func (c *Config) whoIs(ctx context.Context, cred fiken.TokenSource) (string, *Account, error) { + client, err := fiken.NewWithTokens(cred, c.ClientOptions...) + if err != nil { + return "", nil, err + } + user, err := client.User(ctx) + if err != nil { + return "", nil, err + } + email := strings.ToLower(strings.TrimSpace(user.Email)) + if email == "" { + return "", nil, errors.New("fiken did not say which user the login belongs to") + } + companies, err := c.companies(ctx, cred) + if err != nil { + return "", nil, err + } + return email, &Account{Name: user.Name, Companies: companies}, nil +} + +func (c *Config) put(email string, account *Account) error { + if c.Accounts == nil { + c.Accounts = map[string]*Account{} + } + c.Accounts[email] = account + return c.Save() +} + +// Logout revokes an account's grant at Fiken and forgets the account, for +// every program sharing this configuration. A personal token is only +// forgotten: Fiken has nothing to revoke it with. +func (c *Config) Logout(ctx context.Context, email string) error { + account := c.Accounts[email] + if account == nil { + return fmt.Errorf("no Fiken account %q in %s", email, c.dir) + } + var revokeErr error + if account.Token == "" { + source, err := c.Source(email) + if err != nil { + return err + } + if revokeErr = source.Logout(ctx); errors.Is(revokeErr, fikenauth.ErrNoToken) { + revokeErr = nil + } + } + delete(c.Accounts, email) + if err := c.Save(); err != nil { + return err + } + return revokeErr +} + +func pick(values ...string) string { + for _, v := range values { + if v = strings.TrimSpace(v); v != "" { + return v + } + } + return "" +} diff --git a/fikenconfig/config_test.go b/fikenconfig/config_test.go new file mode 100644 index 0000000..2e89b7c --- /dev/null +++ b/fikenconfig/config_test.go @@ -0,0 +1,213 @@ +package fikenconfig + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "simenandre.no/fiken/fiken" + "simenandre.no/fiken/fikenauth" +) + +// fakeFiken answers /user and /companies per bearer token, so each token is a +// different Fiken user. +type fakeFiken struct { + users map[string]fiken.User + companies map[string][]fiken.Company + asked int +} + +func (f *fakeFiken) start(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + user, ok := f.users[token] + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + switch r.URL.Path { + case "/user": + json.NewEncoder(w).Encode(user) + case "/companies": + f.asked++ + w.Header().Set("Fiken-Api-Page-Count", "1") + json.NewEncoder(w).Encode(f.companies[token]) + case "/oauth/revoke": + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + return server +} + +func open(t *testing.T, server *httptest.Server, dir string) *Config { + t.Helper() + c, err := Open(dir) + if err != nil { + t.Fatalf("Open: %v", err) + } + c.LoginCommand = "tool login" + c.ClientOptions = []fiken.Option{fiken.WithBaseURL(server.URL)} + c.AuthOverride = func(a *fikenauth.Config) { + a.RevokeURL = server.URL + "/oauth/revoke" + a.TokenURL = server.URL + "/oauth/token" + } + return c +} + +func twoUsers() *fakeFiken { + return &fakeFiken{ + users: map[string]fiken.User{ + "simen-token": {Name: "Simen", Email: "Simen@Sokkel.io"}, + "bjerk-token": {Name: "Bjerk", Email: "post@bjerk.io"}, + }, + companies: map[string][]fiken.Company{ + "simen-token": {{Name: "Sokkel AS", Slug: "sokkel-as"}}, + "bjerk-token": {{Name: "Bjerk AS", Slug: "bjerk"}}, + }, + } +} + +func TestEachCompanyGetsTheAccountThatReachesIt(t *testing.T) { + t.Setenv(ClientIDEnv, "") + t.Setenv(ClientSecretEnv, "") + fake := twoUsers() + server := fake.start(t) + dir := t.TempDir() + + c := open(t, server, dir) + if _, err := c.AddToken(t.Context(), "bjerk-token"); err != nil { + t.Fatalf("AddToken: %v", err) + } + email, err := c.AddGrant(t.Context(), fikenauth.Token{ + AccessToken: "simen-token", RefreshToken: "r", ExpiresAt: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatalf("AddGrant: %v", err) + } + if email != "simen@sokkel.io" { + t.Errorf("email = %q, want it lowercased", email) + } + + // Another program, opening the same directory afterwards. + other := open(t, server, dir) + for company, want := range map[string]string{"sokkel-as": "simen@sokkel.io", "bjerk": "post@bjerk.io"} { + if got, _ := other.AccountFor(company); got != want { + t.Errorf("AccountFor(%s) = %q, want %q", company, got, want) + } + client, err := other.Client(t.Context(), company) + if err != nil { + t.Fatalf("Client(%s): %v", company, err) + } + if _, err := client.Companies(t.Context()); err != nil { + t.Errorf("Client(%s) could not reach Fiken: %v", company, err) + } + } + + for _, path := range []string{other.Path(), filepath.Join(dir, "accounts", "simen@sokkel.io.json")} { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("%s is %v, want 0600", path, info.Mode().Perm()) + } + } +} + +func TestACompanyAddedSinceIsFoundByAskingAgain(t *testing.T) { + fake := twoUsers() + server := fake.start(t) + c := open(t, server, t.TempDir()) + if _, err := c.AddToken(t.Context(), "simen-token"); err != nil { + t.Fatal(err) + } + + fake.companies["simen-token"] = append(fake.companies["simen-token"], + fiken.Company{Name: "Ny AS", Slug: "ny-as"}) + if _, err := c.Client(t.Context(), "ny-as"); err != nil { + t.Fatalf("Client(ny-as): %v", err) + } + reopened := open(t, server, c.dir) + if _, ok := reopened.AccountFor("ny-as"); !ok { + t.Error("the company found by asking again was not saved") + } + + _, err := c.Client(t.Context(), "nobody-as") + if !errors.Is(err, ErrNoAccount) { + t.Fatalf("err = %v, want ErrNoAccount", err) + } + for _, want := range []string{"ny-as, sokkel-as", "`tool login`"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } +} + +func TestNobodyLoggedIn(t *testing.T) { + c := open(t, twoUsers().start(t), t.TempDir()) + _, err := c.Client(t.Context(), "sokkel-as") + if !errors.Is(err, ErrNoAccount) || !strings.Contains(err.Error(), "nobody is logged in") { + t.Errorf("err = %v", err) + } +} + +func TestTheEnvironmentOverridesTheApp(t *testing.T) { + c := &Config{App: App{ClientID: "file-id", ClientSecret: "file-secret"}} + t.Setenv(ClientIDEnv, "") + t.Setenv(ClientSecretEnv, "env-secret") + got := c.Auth() + if got.ClientID != "file-id" || got.ClientSecret != "env-secret" { + t.Errorf("Auth = %q / %q", got.ClientID, got.ClientSecret) + } +} + +func TestLogoutForgetsTheAccountAndItsGrant(t *testing.T) { + server := twoUsers().start(t) + c := open(t, server, t.TempDir()) + email, err := c.AddGrant(t.Context(), fikenauth.Token{ + AccessToken: "simen-token", RefreshToken: "r", ExpiresAt: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatal(err) + } + if err := c.Logout(t.Context(), email); err != nil { + t.Fatalf("Logout: %v", err) + } + if _, err := os.Stat(filepath.Join(c.dir, "accounts", email+".json")); !os.IsNotExist(err) { + t.Errorf("the grant file is still there: %v", err) + } + if _, ok := open(t, server, c.dir).AccountFor("sokkel-as"); ok { + t.Error("the account is still configured") + } +} + +func TestAnEmailCannotEscapeTheAccountsDirectory(t *testing.T) { + c := &Config{dir: t.TempDir()} + for _, email := range []string{"", "../x", "a/b", ".hidden"} { + if _, err := c.Source(email); err == nil { + t.Errorf("Source(%q) was accepted", email) + } + } +} + +func TestDir(t *testing.T) { + t.Setenv(DirEnv, "") + t.Setenv("XDG_CONFIG_HOME", "/xdg") + if got, _ := Dir(); got != "/xdg/fiken" { + t.Errorf("Dir = %q", got) + } + t.Setenv(DirEnv, "/explicit") + if got, _ := Dir(); got != "/explicit" { + t.Errorf("Dir = %q", got) + } +} |
