// Package fikenauth implements Fiken's OAuth2 authorization code flow. // // It is kept apart from package fiken, which only knows how to put a bearer // token in a header. Where that token came from — a personal API token typed // into an environment variable, or a consent screen and a refresh token on // disk — is this package's business and nobody else's. package fikenauth import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "time" "simenandre.no/fiken/internal/assert" ) // ErrNoToken means nobody has logged in yet, or the stored token was removed. var ErrNoToken = errors.New("not logged in to Fiken") // expirySkew is how early a token is treated as expired. A token that dies // halfway through an invoice run is worse than one refreshed a minute early. const expirySkew = 2 * time.Minute // maxTokenFileBytes bounds what will be read as a token file, so pointing the // setting at the wrong path fails instead of consuming a database. const maxTokenFileBytes = 64 << 10 // Token is a Fiken OAuth2 grant. It is written to disk, so the field names are // part of the file format. type Token struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` ExpiresAt time.Time `json:"expires_at"` } // Valid reports whether the access token can still be used at now, allowing for // the skew. func (t Token) Valid(now time.Time) bool { return t.AccessToken != "" && now.Add(expirySkew).Before(t.ExpiresAt) } // Renewable reports whether a new access token can be obtained without asking // the user to log in again. func (t Token) Renewable() bool { return t.RefreshToken != "" } // String deliberately hides the secrets. A token reaching a log line by way of // %v is a credential leak, and this is the cheapest place to prevent it. func (t Token) String() string { return fmt.Sprintf("fiken oauth token (expires %s)", t.ExpiresAt.Format(time.RFC3339)) } // Store is where the token lives between runs: one file, readable only by its // owner, outside the database. type Store struct{ path string } // NewStore returns a store backed by the file at path. func NewStore(path string) *Store { assert.True(path != "", "token store path must not be empty") return &Store{path: path} } // Path is the file the token is kept in. func (s *Store) Path() string { return s.path } // Load reads the stored token, or returns ErrNoToken if there is none. func (s *Store) Load() (Token, error) { info, err := os.Stat(s.path) if errors.Is(err, os.ErrNotExist) { return Token{}, ErrNoToken } if err != nil { return Token{}, fmt.Errorf("read %s: %w", s.path, err) } if info.Size() > maxTokenFileBytes { return Token{}, fmt.Errorf("%s is %d bytes, which is not a token file", s.path, info.Size()) } raw, err := os.ReadFile(s.path) if err != nil { return Token{}, fmt.Errorf("read %s: %w", s.path, err) } var token Token if err := json.Unmarshal(raw, &token); err != nil { return Token{}, fmt.Errorf("read %s: %w; log in again to replace it", s.path, err) } if token.AccessToken == "" { return Token{}, ErrNoToken } return token, nil } // Save writes the token, replacing any previous one. The file is written to a // temporary name and renamed, so an interrupted write cannot leave a truncated // credential behind. func (s *Store) Save(token Token) error { assert.True(token.AccessToken != "", "refusing to store an empty access token") dir := filepath.Dir(s.path) if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("create %s: %w", dir, err) } raw, err := json.MarshalIndent(token, "", " ") if err != nil { return fmt.Errorf("encode token: %w", err) } temp, err := os.CreateTemp(dir, ".fiken-token-*") if err != nil { return fmt.Errorf("write %s: %w", s.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", s.path, err) } if _, err := temp.Write(append(raw, '\n')); err != nil { temp.Close() return fmt.Errorf("write %s: %w", s.path, err) } if err := temp.Close(); err != nil { return fmt.Errorf("write %s: %w", s.path, err) } if err := os.Rename(temp.Name(), s.path); err != nil { return fmt.Errorf("write %s: %w", s.path, err) } return nil } // Delete removes the stored token. Deleting one that is not there succeeds: // logging out twice is not an error. func (s *Store) Delete() error { err := os.Remove(s.path) if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("remove %s: %w", s.path, err) } return nil }