summaryrefslogtreecommitdiffstats
path: root/fikenauth/source.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/source.go
downloadfiken-c0a01ac65bb492640b169274614e0d8022f5076d.tar.gz
fiken-c0a01ac65bb492640b169274614e0d8022f5076d.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/source.go')
-rw-r--r--fikenauth/source.go125
1 files changed, 125 insertions, 0 deletions
diff --git a/fikenauth/source.go b/fikenauth/source.go
new file mode 100644
index 0000000..f6289c2
--- /dev/null
+++ b/fikenauth/source.go
@@ -0,0 +1,125 @@
+package fikenauth
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+)
+
+// Source hands out a usable access token, refreshing and re-storing it when the
+// stored one has expired. It satisfies fiken.TokenSource.
+//
+// Refreshing is a side effect of asking for a token rather than a step the
+// caller has to remember, which is the only way it can be got right everywhere.
+type Source struct {
+ cfg Config
+ store *Store
+
+ // LoginCommand is what to tell the operator to run when the grant can no
+ // longer be renewed. Every program embedding this spells its own login
+ // differently, and an error naming the wrong one sends the reader to a
+ // command that does not exist.
+ LoginCommand string
+
+ mu sync.Mutex
+ token Token
+ loaded bool
+}
+
+// NewSource returns a token source backed by store.
+func NewSource(cfg Config, store *Store) *Source {
+ return &Source{cfg: cfg, store: store}
+}
+
+// loginHint is how to ask for a new grant, in words that are true whatever the
+// caller called its command.
+func (s *Source) loginHint() string {
+ if s.LoginCommand == "" {
+ return "log in to Fiken again"
+ }
+ return "run `" + s.LoginCommand + "` again"
+}
+
+// Token returns an access token that is valid now.
+func (s *Source) Token(ctx context.Context) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := s.load(); err != nil {
+ return "", err
+ }
+ if s.token.Valid(s.cfg.now()) {
+ return s.token.AccessToken, nil
+ }
+ if !s.token.Renewable() {
+ return "", fmt.Errorf("the Fiken access token has expired and cannot be renewed: %w", ErrNoToken)
+ }
+
+ if err := s.cfg.Validate(); err != nil {
+ return "", err
+ }
+ refreshed, err := s.cfg.Refresh(ctx, s.token.RefreshToken)
+ if err != nil {
+ var oauthErr *Error
+ if errors.As(err, &oauthErr) {
+ return "", fmt.Errorf(
+ "could not renew the Fiken login (%s); %s", oauthErr.Code, s.loginHint())
+ }
+ return "", err
+ }
+
+ // Save before returning: a token used but not stored would be refreshed
+ // again on the next run, and Fiken invalidates the old refresh token.
+ if err := s.store.Save(refreshed); err != nil {
+ return "", err
+ }
+ s.token = refreshed
+ return refreshed.AccessToken, nil
+}
+
+// Current returns the stored token as it stands, without refreshing it, which
+// is what a `fiken status` command reports on.
+func (s *Source) Current() (Token, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if err := s.load(); err != nil {
+ return Token{}, err
+ }
+ return s.token, nil
+}
+
+// Logout revokes the grant at Fiken and removes the stored token. The file is
+// removed even if the revoke fails, because a token that cannot be revoked is
+// exactly the one not to keep lying around.
+func (s *Source) Logout(ctx context.Context) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := s.load(); err != nil {
+ return err
+ }
+ revokeErr := s.cfg.Revoke(ctx, s.token.AccessToken)
+
+ s.token, s.loaded = Token{}, false
+ if err := s.store.Delete(); err != nil {
+ return err
+ }
+ return revokeErr
+}
+
+// Store is where this source keeps its token, for messages.
+func (s *Source) Store() *Store { return s.store }
+
+// load reads the token once per process.
+func (s *Source) load() error {
+ if s.loaded {
+ return nil
+ }
+ token, err := s.store.Load()
+ if err != nil {
+ return err
+ }
+ s.token, s.loaded = token, true
+ return nil
+}