summaryrefslogtreecommitdiffstats
path: root/fikenauth/token.go
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-10-17 14:19:09 +0200
committerSimen A. W. Olsen <hello@simenandre.no>2026-10-17 14:19:09 +0200
commitc0a01ac65bb492640b169274614e0d8022f5076d (patch)
treef7d3f0e81d1a9dc05288ba24f1ecc57387f3e0d5 /fikenauth/token.go
downloadfiken-f112e25ee77c31812d0c59265284cc410bbb3b97.tar.gz
fiken-f112e25ee77c31812d0c59265284cc410bbb3b97.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.go143
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
+}