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/config.go | |
| parent | a5491c9dccdf4678bf3b4380d208cf2562afeee4 (diff) | |
| download | fiken-bef3ccf4521d4ed431f3cdfc66e618c619ce28e4.tar.gz fiken-bef3ccf4521d4ed431f3cdfc66e618c619ce28e4.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/config.go')
| -rw-r--r-- | fikenconfig/config.go | 436 |
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 "" +} |
