diff options
| author | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-17 14:19:09 +0200 |
|---|---|---|
| committer | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-17 14:19:09 +0200 |
| commit | c0a01ac65bb492640b169274614e0d8022f5076d (patch) | |
| tree | f7d3f0e81d1a9dc05288ba24f1ecc57387f3e0d5 /fikenauth/token.go | |
| download | fiken-0.1.0.tar.gz fiken-0.1.0.zip | |
Fiken API client and OAuth2 for Gov0.1.0
Extracted from gcbill, where it was internal/fiken and internal/fikenauth,
so adaa and sokkel-billing can use the same client instead of each
carrying its own.
The login command named when a grant cannot be renewed is now injected
(Source.LoginCommand): it was hardcoded to gcbill's, which would have sent
every other caller's operator to a command that does not exist.
Diffstat (limited to 'fikenauth/token.go')
| -rw-r--r-- | fikenauth/token.go | 143 |
1 files changed, 143 insertions, 0 deletions
diff --git a/fikenauth/token.go b/fikenauth/token.go new file mode 100644 index 0000000..dcda79a --- /dev/null +++ b/fikenauth/token.go @@ -0,0 +1,143 @@ +// 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 +} |
