// 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 "" }