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/source.go | |
| download | fiken-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/source.go')
| -rw-r--r-- | fikenauth/source.go | 125 |
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 +} |
