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 }