summaryrefslogtreecommitdiffstats
path: root/fikenconfig/config.go
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 /fikenconfig/config.go
parenta5491c9dccdf4678bf3b4380d208cf2562afeee4 (diff)
downloadfiken-bef3ccf4521d4ed431f3cdfc66e618c619ce28e4.tar.gz
fiken-bef3ccf4521d4ed431f3cdfc66e618c619ce28e4.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 'fikenconfig/config.go')
-rw-r--r--fikenconfig/config.go436
1 files changed, 436 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 ""
+}