summaryrefslogtreecommitdiffstats
path: root/fikenauth
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
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')
-rw-r--r--fikenauth/login.go217
-rw-r--r--fikenauth/oauth.go246
-rw-r--r--fikenauth/oauth_test.go264
-rw-r--r--fikenauth/source.go125
-rw-r--r--fikenauth/source_test.go172
-rw-r--r--fikenauth/store_test.go298
-rw-r--r--fikenauth/token.go143
7 files changed, 1465 insertions, 0 deletions
diff --git a/fikenauth/login.go b/fikenauth/login.go
new file mode 100644
index 0000000..e438755
--- /dev/null
+++ b/fikenauth/login.go
@@ -0,0 +1,217 @@
+package fikenauth
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "html"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// loginTimeout bounds how long the local server waits for the browser. Long
+// enough to find a password, short enough that a forgotten terminal frees the
+// port again.
+const loginTimeout = 5 * time.Minute
+
+// shutdownGrace is how long the local server is given to finish writing the
+// page the browser is waiting on.
+const shutdownGrace = 5 * time.Second
+
+// NewState returns an unguessable value to tie the redirect back to this run.
+func NewState() (string, error) {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("generate an OAuth state: %w", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(buf), nil
+}
+
+// callback is what the browser came back with.
+type callback struct {
+ code string
+ state string
+ err error
+}
+
+// Login runs the loopback flow: a local server listens on the redirect URL's
+// port, announce is called with the URL to visit, and the token is exchanged
+// once the browser returns.
+//
+// The redirect URL must be registered on the Fiken app exactly as configured.
+func (c Config) Login(ctx context.Context, announce func(authURL string)) (Token, error) {
+ if err := c.Validate(); err != nil {
+ return Token{}, err
+ }
+
+ redirect, err := url.Parse(c.redirectURL())
+ if err != nil {
+ return Token{}, fmt.Errorf("fiken.redirect_url %q is not a URL: %w", c.redirectURL(), err)
+ }
+ if !isLoopback(redirect.Hostname()) {
+ return Token{}, fmt.Errorf(
+ "fiken.redirect_url %q does not point at this machine; "+
+ "use a localhost URL, or log in with --manual", c.redirectURL())
+ }
+
+ listener, err := net.Listen("tcp", net.JoinHostPort(redirect.Hostname(), portOf(redirect)))
+ if err != nil {
+ return Token{}, fmt.Errorf(
+ "listen on %s for the Fiken redirect: %w; "+
+ "close whatever is using the port, or log in with --manual", redirect.Host, err)
+ }
+
+ state, err := NewState()
+ if err != nil {
+ return Token{}, err
+ }
+
+ results := make(chan callback, 1)
+ server := &http.Server{
+ Handler: handler(redirect.Path, results),
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+ go server.Serve(listener) //nolint:errcheck // Serve always ends in an error on close
+
+ // Shut down rather than close: the browser is still being sent the page
+ // that says how it went, and closing the moment the code arrives leaves
+ // the operator looking at a blank tab.
+ defer func() {
+ grace, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownGrace)
+ defer cancel()
+ server.Shutdown(grace) //nolint:errcheck // nothing useful to do if it will not
+ }()
+
+ announce(c.AuthCodeURL(state))
+
+ timeout := time.NewTimer(loginTimeout)
+ defer timeout.Stop()
+
+ select {
+ case <-ctx.Done():
+ return Token{}, ctx.Err()
+ case <-timeout.C:
+ return Token{}, fmt.Errorf("gave up waiting for the Fiken login after %s", loginTimeout)
+ case result := <-results:
+ if result.err != nil {
+ return Token{}, result.err
+ }
+ if result.state != state {
+ return Token{}, errors.New("the Fiken redirect carried the wrong state; the login was not completed here")
+ }
+ return c.Exchange(ctx, result.code, state)
+ }
+}
+
+// handler answers the one request that matters and ignores the rest, so a
+// browser fetching a favicon does not end the login.
+func handler(path string, results chan<- callback) http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if path != "" && r.URL.Path != path {
+ http.NotFound(w, r)
+ return
+ }
+
+ result := fromQuery(r.URL.Query())
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if result.err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ io.WriteString(w, page("Innlogging avbrutt", result.err.Error())) //nolint:errcheck
+ } else {
+ io.WriteString(w, page("Innlogging fullført", //nolint:errcheck
+ "Du kan lukke dette vinduet og gå tilbake til terminalen."))
+ }
+
+ select {
+ case results <- result:
+ default: // a second request cannot change the outcome
+ }
+ })
+ return mux
+}
+
+// fromQuery reads the redirect's parameters, turning Fiken's error reply into
+// an error rather than an empty code.
+func fromQuery(query url.Values) callback {
+ if code := query.Get("error"); code != "" {
+ return callback{err: &Error{Code: code, Description: query.Get("error_description")}}
+ }
+ code := query.Get("code")
+ if code == "" {
+ return callback{err: errors.New("the Fiken redirect carried no authorization code")}
+ }
+ return callback{code: code, state: query.Get("state")}
+}
+
+// page renders the one-shot browser response. The message can contain an error
+// straight from Fiken, so it is escaped rather than trusted.
+func page(title, message string) string {
+ return "<!doctype html><meta charset=\"utf-8\"><title>" + html.EscapeString(title) +
+ "</title><body style=\"font-family:system-ui;margin:4rem auto;max-width:32rem\">" +
+ "<h1>" + html.EscapeString(title) + "</h1><p>" + html.EscapeString(message) + "</p>"
+}
+
+// ManualAuthCodeURL starts a login that does not need a local server: the user
+// visits the URL, then pastes back where the browser ended up. This is the way
+// in over SSH, or when the redirect port is unavailable.
+func (c Config) ManualAuthCodeURL() (authURL, state string, err error) {
+ if err := c.Validate(); err != nil {
+ return "", "", err
+ }
+ state, err = NewState()
+ if err != nil {
+ return "", "", err
+ }
+ return c.AuthCodeURL(state), state, nil
+}
+
+// CompleteManual finishes a manual login from the URL the browser was
+// redirected to. A bare authorization code is accepted too, for the case where
+// the browser showed one instead of navigating.
+func (c Config) CompleteManual(ctx context.Context, state, redirected string) (Token, error) {
+ redirected = strings.TrimSpace(redirected)
+ if redirected == "" {
+ return Token{}, errors.New("nothing pasted")
+ }
+
+ // A bare authorization code is accepted as well as a whole URL, because
+ // some browsers show the code rather than navigating anywhere.
+ if !strings.Contains(redirected, "?") && !strings.Contains(redirected, "://") {
+ return c.Exchange(ctx, redirected, state)
+ }
+
+ parsed, err := url.Parse(redirected)
+ if err != nil {
+ return Token{}, fmt.Errorf("that is not a URL: %w", err)
+ }
+ result := fromQuery(parsed.Query())
+ if result.err != nil {
+ return Token{}, result.err
+ }
+ if result.state != "" && result.state != state {
+ return Token{}, errors.New("the pasted URL is from a different login attempt")
+ }
+ return c.Exchange(ctx, result.code, state)
+}
+
+func isLoopback(host string) bool {
+ if host == "localhost" {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+func portOf(u *url.URL) string {
+ if port := u.Port(); port != "" {
+ return port
+ }
+ return "80"
+}
diff --git a/fikenauth/oauth.go b/fikenauth/oauth.go
new file mode 100644
index 0000000..f899ccb
--- /dev/null
+++ b/fikenauth/oauth.go
@@ -0,0 +1,246 @@
+package fikenauth
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// Fiken's OAuth2 endpoints. They live on fiken.no, not on the API host.
+const (
+ DefaultAuthorizeURL = "https://fiken.no/oauth/authorize"
+ DefaultTokenURL = "https://fiken.no/oauth/token"
+ DefaultRevokeURL = "https://fiken.no/oauth/revoke"
+)
+
+// DefaultRedirectURL is where the browser is sent back to. It must be
+// registered on the app in Fiken, character for character.
+const DefaultRedirectURL = "http://localhost:8137/callback"
+
+const (
+ tokenRequestTimeout = 30 * time.Second
+ maxResponseBytes = 1 << 20
+)
+
+// Config is an OAuth2 app registration plus where to send the browser.
+type Config struct {
+ ClientID string
+ ClientSecret string
+ RedirectURL string
+
+ // Endpoints default to Fiken's. They are fields so that the tests can point
+ // the flow at a local server.
+ AuthorizeURL string
+ TokenURL string
+ RevokeURL string
+
+ HTTPClient *http.Client
+ Now func() time.Time
+}
+
+// Error is a refusal from the OAuth endpoints, carrying what Fiken said.
+type Error struct {
+ Code string `json:"error"`
+ Description string `json:"error_description"`
+}
+
+func (e *Error) Error() string {
+ if e.Description == "" {
+ return "fiken oauth: " + e.Code
+ }
+ return fmt.Sprintf("fiken oauth: %s: %s", e.Code, e.Description)
+}
+
+// AccessDenied reports whether the user declined the consent screen, which is a
+// decision rather than a fault.
+func (e *Error) AccessDenied() bool { return e.Code == "access_denied" }
+
+// Validate reports what is missing from an app registration.
+func (c Config) Validate() error {
+ var missing []string
+ if c.ClientID == "" {
+ missing = append(missing, "fiken.client_id")
+ }
+ if c.ClientSecret == "" {
+ missing = append(missing, "fiken.client_secret_file (or FIKEN_CLIENT_SECRET)")
+ }
+ if len(missing) > 0 {
+ return fmt.Errorf(
+ "no Fiken OAuth app configured: set %s. Create the app in Fiken under "+
+ "Brukerinnstillinger -> API, registering %s as the redirect URL",
+ strings.Join(missing, " and "), c.redirectURL())
+ }
+ if _, err := url.Parse(c.redirectURL()); err != nil {
+ return fmt.Errorf("fiken.redirect_url %q is not a URL: %w", c.redirectURL(), err)
+ }
+ return nil
+}
+
+func (c Config) redirectURL() string { return orDefault(c.RedirectURL, DefaultRedirectURL) }
+func (c Config) authorizeURL() string {
+ return orDefault(c.AuthorizeURL, DefaultAuthorizeURL)
+}
+func (c Config) tokenURL() string { return orDefault(c.TokenURL, DefaultTokenURL) }
+func (c Config) revokeURL() string { return orDefault(c.RevokeURL, DefaultRevokeURL) }
+
+func (c Config) now() time.Time {
+ if c.Now == nil {
+ return time.Now()
+ }
+ return c.Now()
+}
+
+func (c Config) httpClient() *http.Client {
+ if c.HTTPClient != nil {
+ return c.HTTPClient
+ }
+ return &http.Client{Timeout: tokenRequestTimeout}
+}
+
+func orDefault(value, fallback string) string {
+ if value == "" {
+ return fallback
+ }
+ return value
+}
+
+// AuthCodeURL is where the user is sent to grant consent. The state is
+// unguessable and is checked when the browser comes back, which is what stops
+// somebody else's redirect being accepted as this one.
+func (c Config) AuthCodeURL(state string) string {
+ query := url.Values{
+ "response_type": {"code"},
+ "client_id": {c.ClientID},
+ "redirect_uri": {c.redirectURL()},
+ "state": {state},
+ }
+ return c.authorizeURL() + "?" + query.Encode()
+}
+
+// Exchange turns an authorization code into a token.
+func (c Config) Exchange(ctx context.Context, code, state string) (Token, error) {
+ if code == "" {
+ return Token{}, errors.New("fiken returned no authorization code")
+ }
+ return c.postToken(ctx, url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {code},
+ "redirect_uri": {c.redirectURL()},
+ "state": {state},
+ })
+}
+
+// Refresh exchanges a refresh token for a fresh access token. Fiken may return
+// a new refresh token as well, so the whole result is what gets stored.
+func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
+ if refreshToken == "" {
+ return Token{}, ErrNoToken
+ }
+ token, err := c.postToken(ctx, url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {refreshToken},
+ })
+ if err != nil {
+ return Token{}, err
+ }
+ // Fiken need not reissue the refresh token; keep the one that still works.
+ if token.RefreshToken == "" {
+ token.RefreshToken = refreshToken
+ }
+ return token, nil
+}
+
+// Revoke disables an access token and the refresh token behind it.
+func (c Config) Revoke(ctx context.Context, accessToken string) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.revokeURL(), nil)
+ if err != nil {
+ return fmt.Errorf("build revoke request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+
+ resp, err := c.httpClient().Do(req)
+ if err != nil {
+ return fmt.Errorf("revoke Fiken token: %w", err)
+ }
+ defer resp.Body.Close()
+
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
+ if resp.StatusCode >= 400 {
+ if oauthErr := decodeError(raw); oauthErr != nil {
+ return oauthErr
+ }
+ return fmt.Errorf("revoke Fiken token: HTTP %d", resp.StatusCode)
+ }
+ return nil
+}
+
+// tokenResponse is what the token endpoint returns. expires_in is relative, so
+// it is turned into an absolute time here, once, against this run's clock.
+type tokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+}
+
+// postToken calls the token endpoint. It is protected with HTTP Basic
+// authentication using the app's client id and secret.
+func (c Config) postToken(ctx context.Context, form url.Values) (Token, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL(),
+ strings.NewReader(form.Encode()))
+ if err != nil {
+ return Token{}, fmt.Errorf("build token request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+ req.SetBasicAuth(c.ClientID, c.ClientSecret)
+
+ resp, err := c.httpClient().Do(req)
+ if err != nil {
+ return Token{}, fmt.Errorf("reach the Fiken token endpoint: %w", err)
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
+ if err != nil {
+ return Token{}, fmt.Errorf("read the Fiken token response: %w", err)
+ }
+ if oauthErr := decodeError(raw); oauthErr != nil {
+ return Token{}, oauthErr
+ }
+ if resp.StatusCode >= 400 {
+ return Token{}, fmt.Errorf("fiken token endpoint: HTTP %d: %s",
+ resp.StatusCode, strings.TrimSpace(string(raw)))
+ }
+
+ var body tokenResponse
+ if err := json.Unmarshal(raw, &body); err != nil {
+ return Token{}, fmt.Errorf("unreadable Fiken token response: %w", err)
+ }
+ if body.AccessToken == "" {
+ return Token{}, errors.New("fiken returned no access token")
+ }
+
+ return Token{
+ AccessToken: body.AccessToken,
+ RefreshToken: body.RefreshToken,
+ TokenType: orDefault(body.TokenType, "bearer"),
+ ExpiresAt: c.now().Add(time.Duration(body.ExpiresIn) * time.Second),
+ }, nil
+}
+
+// decodeError returns an *Error if the body is one, and nil otherwise. Fiken
+// reports OAuth failures in the body, sometimes with a 200.
+func decodeError(raw []byte) error {
+ var body Error
+ if err := json.Unmarshal(raw, &body); err != nil || body.Code == "" {
+ return nil
+ }
+ return &body
+}
diff --git a/fikenauth/oauth_test.go b/fikenauth/oauth_test.go
new file mode 100644
index 0000000..fd9234e
--- /dev/null
+++ b/fikenauth/oauth_test.go
@@ -0,0 +1,264 @@
+package fikenauth
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+var fixedNow = time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
+
+// authServer stands in for fiken.no's OAuth endpoints.
+type authServer struct {
+ server *httptest.Server
+
+ gotBasicUser string
+ gotBasicPass string
+ gotForm url.Values
+ gotRevokeAuth string
+
+ reply string
+ replyCode int
+ refreshOut string
+}
+
+func newAuthServer(t *testing.T) *authServer {
+ t.Helper()
+ s := &authServer{
+ reply: `{"access_token":"access-1","refresh_token":"refresh-1","token_type":"bearer","expires_in":86157}`,
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
+ s.gotBasicUser, s.gotBasicPass, _ = r.BasicAuth()
+ r.ParseForm() //nolint:errcheck
+ s.gotForm = r.PostForm
+
+ body := s.reply
+ if r.PostForm.Get("grant_type") == "refresh_token" && s.refreshOut != "" {
+ body = s.refreshOut
+ }
+ if s.replyCode != 0 {
+ w.WriteHeader(s.replyCode)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(body)) //nolint:errcheck
+ })
+ mux.HandleFunc("/oauth/revoke", func(w http.ResponseWriter, r *http.Request) {
+ s.gotRevokeAuth = r.Header.Get("Authorization")
+ if s.replyCode != 0 {
+ w.WriteHeader(s.replyCode)
+ w.Write([]byte(s.reply)) //nolint:errcheck
+ }
+ })
+ s.server = httptest.NewServer(mux)
+ t.Cleanup(s.server.Close)
+ return s
+}
+
+func (s *authServer) config() Config {
+ return Config{
+ ClientID: "client-abc",
+ ClientSecret: "secret-xyz",
+ RedirectURL: "http://localhost:8137/callback",
+ AuthorizeURL: s.server.URL + "/oauth/authorize",
+ TokenURL: s.server.URL + "/oauth/token",
+ RevokeURL: s.server.URL + "/oauth/revoke",
+ HTTPClient: s.server.Client(),
+ Now: func() time.Time { return fixedNow },
+ }
+}
+
+func TestValidateNamesWhatIsMissing(t *testing.T) {
+ err := Config{}.Validate()
+ if err == nil {
+ t.Fatal("an empty config validated")
+ }
+ for _, want := range []string{"client_id", "client_secret", DefaultRedirectURL} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("error %q should mention %q", err, want)
+ }
+ }
+ if err := (Config{ClientID: "a", ClientSecret: "b"}).Validate(); err != nil {
+ t.Errorf("a complete config was rejected: %v", err)
+ }
+}
+
+func TestAuthCodeURL(t *testing.T) {
+ got, err := url.Parse(newAuthServer(t).config().AuthCodeURL("state-123"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ query := got.Query()
+ for field, want := range map[string]string{
+ "response_type": "code",
+ "client_id": "client-abc",
+ "redirect_uri": "http://localhost:8137/callback",
+ "state": "state-123",
+ } {
+ if query.Get(field) != want {
+ t.Errorf("%s = %q, want %q", field, query.Get(field), want)
+ }
+ }
+}
+
+// The token endpoint is protected with HTTP Basic authentication using the app
+// credentials, not by putting them in the form.
+func TestExchangeUsesBasicAuth(t *testing.T) {
+ server := newAuthServer(t)
+ token, err := server.config().Exchange(context.Background(), "auth-code", "state-123")
+ if err != nil {
+ t.Fatalf("Exchange: %v", err)
+ }
+
+ if server.gotBasicUser != "client-abc" || server.gotBasicPass != "secret-xyz" {
+ t.Errorf("basic auth = %q:%q", server.gotBasicUser, server.gotBasicPass)
+ }
+ if got := server.gotForm.Get("grant_type"); got != "authorization_code" {
+ t.Errorf("grant_type = %q", got)
+ }
+ if got := server.gotForm.Get("code"); got != "auth-code" {
+ t.Errorf("code = %q", got)
+ }
+ if got := server.gotForm.Get("redirect_uri"); got != "http://localhost:8137/callback" {
+ t.Errorf("redirect_uri = %q", got)
+ }
+ if token.AccessToken != "access-1" || token.RefreshToken != "refresh-1" {
+ t.Errorf("token = %+v", token)
+ }
+
+ // expires_in is relative; it becomes absolute here, once.
+ want := fixedNow.Add(86157 * time.Second)
+ if !token.ExpiresAt.Equal(want) {
+ t.Errorf("ExpiresAt = %s, want %s", token.ExpiresAt, want)
+ }
+}
+
+func TestExchangeRejectsAnEmptyCode(t *testing.T) {
+ if _, err := newAuthServer(t).config().Exchange(context.Background(), "", "s"); err == nil {
+ t.Fatal("Exchange accepted an empty code")
+ }
+}
+
+// Fiken need not reissue the refresh token, and throwing away a working one
+// would force a fresh login for no reason.
+func TestRefreshKeepsTheOldRefreshTokenWhenNoneIsReturned(t *testing.T) {
+ server := newAuthServer(t)
+ server.refreshOut = `{"access_token":"access-2","token_type":"bearer","expires_in":3600}`
+
+ token, err := server.config().Refresh(context.Background(), "refresh-1")
+ if err != nil {
+ t.Fatalf("Refresh: %v", err)
+ }
+ if token.AccessToken != "access-2" {
+ t.Errorf("AccessToken = %q", token.AccessToken)
+ }
+ if token.RefreshToken != "refresh-1" {
+ t.Errorf("RefreshToken = %q, want the one that still works", token.RefreshToken)
+ }
+ if got := server.gotForm.Get("grant_type"); got != "refresh_token" {
+ t.Errorf("grant_type = %q", got)
+ }
+}
+
+func TestRefreshTakesANewRefreshTokenWhenGiven(t *testing.T) {
+ server := newAuthServer(t)
+ server.refreshOut = `{"access_token":"access-2","refresh_token":"refresh-2","expires_in":3600}`
+
+ token, err := server.config().Refresh(context.Background(), "refresh-1")
+ if err != nil {
+ t.Fatalf("Refresh: %v", err)
+ }
+ if token.RefreshToken != "refresh-2" {
+ t.Errorf("RefreshToken = %q, want the reissued one", token.RefreshToken)
+ }
+}
+
+func TestRefreshWithoutATokenIsNoToken(t *testing.T) {
+ if _, err := newAuthServer(t).config().Refresh(context.Background(), ""); !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
+
+func TestOAuthErrorsAreCarried(t *testing.T) {
+ server := newAuthServer(t)
+ server.replyCode = http.StatusBadRequest
+ server.reply = `{"error":"invalid_grant","error_description":"The authorization code is invalid or expired."}`
+
+ _, err := server.config().Exchange(context.Background(), "stale", "state")
+ var oauthErr *Error
+ if !errors.As(err, &oauthErr) {
+ t.Fatalf("err = %v, want an *Error", err)
+ }
+ if oauthErr.Code != "invalid_grant" {
+ t.Errorf("Code = %q", oauthErr.Code)
+ }
+ if !strings.Contains(err.Error(), "invalid or expired") {
+ t.Errorf("error %q should carry Fiken's description", err)
+ }
+ if oauthErr.AccessDenied() {
+ t.Error("invalid_grant is not access_denied")
+ }
+}
+
+func TestRevoke(t *testing.T) {
+ server := newAuthServer(t)
+ if err := server.config().Revoke(context.Background(), "access-1"); err != nil {
+ t.Fatalf("Revoke: %v", err)
+ }
+ if server.gotRevokeAuth != "Bearer access-1" {
+ t.Errorf("revoke Authorization = %q", server.gotRevokeAuth)
+ }
+}
+
+func TestRevokeReportsFailure(t *testing.T) {
+ server := newAuthServer(t)
+ server.replyCode = http.StatusUnauthorized
+ server.reply = `{"error":"invalid_token","error_description":"nope"}`
+
+ err := server.config().Revoke(context.Background(), "access-1")
+ var oauthErr *Error
+ if !errors.As(err, &oauthErr) || oauthErr.Code != "invalid_token" {
+ t.Errorf("err = %v, want an invalid_token *Error", err)
+ }
+}
+
+// A token must never reach a log line through %v.
+func TestTokenStringHidesSecrets(t *testing.T) {
+ token := Token{AccessToken: "super-secret", RefreshToken: "also-secret", ExpiresAt: fixedNow}
+ rendered := token.String()
+ if strings.Contains(rendered, "secret") {
+ t.Errorf("String() leaked a credential: %q", rendered)
+ }
+
+ // The JSON form does carry them: that is the file format.
+ raw, err := json.Marshal(token)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(raw), "super-secret") {
+ t.Error("the stored form should keep the token")
+ }
+}
+
+func TestTokenValidity(t *testing.T) {
+ token := Token{AccessToken: "a", ExpiresAt: fixedNow.Add(time.Hour)}
+ if !token.Valid(fixedNow) {
+ t.Error("a token good for an hour reported invalid")
+ }
+ // The skew makes a token that dies in a moment count as already dead.
+ if token.Valid(fixedNow.Add(time.Hour - time.Minute)) {
+ t.Error("the expiry skew was not applied")
+ }
+ if (Token{ExpiresAt: fixedNow.Add(time.Hour)}).Valid(fixedNow) {
+ t.Error("a token with no access token is not valid")
+ }
+ if token.Renewable() {
+ t.Error("a token with no refresh token is not renewable")
+ }
+}
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
+}
diff --git a/fikenauth/source_test.go b/fikenauth/source_test.go
new file mode 100644
index 0000000..1c960d6
--- /dev/null
+++ b/fikenauth/source_test.go
@@ -0,0 +1,172 @@
+package fikenauth
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestSourceReturnsAValidTokenWithoutRefreshing(t *testing.T) {
+ server := newAuthServer(t)
+ store := tempStore(t)
+ if err := store.Save(Token{
+ AccessToken: "still-good", RefreshToken: "r1",
+ ExpiresAt: fixedNow.Add(time.Hour),
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := NewSource(server.config(), store).Token(t.Context())
+ if err != nil {
+ t.Fatalf("Token: %v", err)
+ }
+ if got != "still-good" {
+ t.Errorf("Token = %q", got)
+ }
+ if server.gotForm != nil {
+ t.Error("the token endpoint was called for a token that was still valid")
+ }
+}
+
+// An expired token is renewed and the new one stored, because Fiken invalidates
+// the refresh token once it is used: not saving would strand the next run.
+func TestSourceRefreshesAndPersists(t *testing.T) {
+ server := newAuthServer(t)
+ server.refreshOut = `{"access_token":"access-2","refresh_token":"refresh-2","expires_in":3600}`
+ store := tempStore(t)
+ if err := store.Save(Token{
+ AccessToken: "expired", RefreshToken: "refresh-1",
+ ExpiresAt: fixedNow.Add(-time.Hour),
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ source := NewSource(server.config(), store)
+ got, err := source.Token(t.Context())
+ if err != nil {
+ t.Fatalf("Token: %v", err)
+ }
+ if got != "access-2" {
+ t.Errorf("Token = %q, want the refreshed one", got)
+ }
+ if sent := server.gotForm.Get("refresh_token"); sent != "refresh-1" {
+ t.Errorf("refreshed with %q", sent)
+ }
+
+ stored, err := store.Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.AccessToken != "access-2" || stored.RefreshToken != "refresh-2" {
+ t.Errorf("stored %+v, want the refreshed grant", stored)
+ }
+
+ // A second call within the same run reuses what it already has.
+ server.gotForm = nil
+ if _, err := source.Token(t.Context()); err != nil {
+ t.Fatalf("second Token: %v", err)
+ }
+ if server.gotForm != nil {
+ t.Error("the token was refreshed twice in one run")
+ }
+}
+
+func TestSourceWithoutAStoredToken(t *testing.T) {
+ _, err := NewSource(newAuthServer(t).config(), tempStore(t)).Token(t.Context())
+ if !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
+
+func TestSourceCannotRenewWithoutARefreshToken(t *testing.T) {
+ store := tempStore(t)
+ if err := store.Save(Token{AccessToken: "expired", ExpiresAt: fixedNow.Add(-time.Hour)}); err != nil {
+ t.Fatal(err)
+ }
+ _, err := NewSource(newAuthServer(t).config(), store).Token(t.Context())
+ if !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
+
+// A revoked grant should tell the operator what to do, not report a raw
+// OAuth code at them.
+func TestSourceExplainsARevokedGrant(t *testing.T) {
+ server := newAuthServer(t)
+ server.replyCode = http.StatusBadRequest
+ server.reply = `{"error":"invalid_grant","error_description":"revoked"}`
+ store := tempStore(t)
+ if err := store.Save(Token{
+ AccessToken: "expired", RefreshToken: "r1",
+ ExpiresAt: fixedNow.Add(-time.Hour),
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ // A caller that says what its login command is gets named in the error,
+ // because every program embedding this spells it differently.
+ source := NewSource(server.config(), store)
+ source.LoginCommand = "bill fiken login"
+ _, err := source.Token(t.Context())
+ if err == nil || !strings.Contains(err.Error(), "run `bill fiken login` again") {
+ t.Fatalf("err = %v, want it to name the caller's login command", err)
+ }
+
+ // One that does not still has to say what to do, without inventing a
+ // command that does not exist.
+ _, err = NewSource(server.config(), store).Token(t.Context())
+ switch {
+ case err == nil || !strings.Contains(err.Error(), "log in to Fiken again"):
+ t.Fatalf("err = %v, want it to say how to recover", err)
+ case strings.Contains(err.Error(), "`"):
+ t.Errorf("err = %v, want no command quoted when none was given", err)
+ }
+}
+
+func TestLogoutRevokesAndForgets(t *testing.T) {
+ server := newAuthServer(t)
+ store := tempStore(t)
+ if err := store.Save(Token{
+ AccessToken: "access-1", RefreshToken: "r1",
+ ExpiresAt: fixedNow.Add(time.Hour),
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ source := NewSource(server.config(), store)
+ if err := source.Logout(t.Context()); err != nil {
+ t.Fatalf("Logout: %v", err)
+ }
+ if server.gotRevokeAuth != "Bearer access-1" {
+ t.Errorf("revoke Authorization = %q", server.gotRevokeAuth)
+ }
+ if _, err := store.Load(); !errors.Is(err, ErrNoToken) {
+ t.Errorf("the token file survived logout: %v", err)
+ }
+}
+
+// A token that cannot be revoked is exactly the one not to keep on disk.
+func TestLogoutForgetsEvenWhenRevokeFails(t *testing.T) {
+ server := newAuthServer(t)
+ server.replyCode = http.StatusInternalServerError
+ store := tempStore(t)
+ if err := store.Save(Token{AccessToken: "access-1", ExpiresAt: fixedNow.Add(time.Hour)}); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := NewSource(server.config(), store).Logout(t.Context()); err == nil {
+ t.Error("Logout hid the revoke failure")
+ }
+ if _, err := store.Load(); !errors.Is(err, ErrNoToken) {
+ t.Errorf("the token file survived logout: %v", err)
+ }
+}
+
+func TestLogoutWithoutATokenIsNoToken(t *testing.T) {
+ err := NewSource(newAuthServer(t).config(), tempStore(t)).Logout(t.Context())
+ if !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
diff --git a/fikenauth/store_test.go b/fikenauth/store_test.go
new file mode 100644
index 0000000..bcd940b
--- /dev/null
+++ b/fikenauth/store_test.go
@@ -0,0 +1,298 @@
+package fikenauth
+
+import (
+ "errors"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func tempStore(t *testing.T) *Store {
+ t.Helper()
+ return NewStore(filepath.Join(t.TempDir(), "nested", "fiken-token.json"))
+}
+
+func TestStoreRoundTrip(t *testing.T) {
+ store := tempStore(t)
+ if _, err := store.Load(); !errors.Is(err, ErrNoToken) {
+ t.Fatalf("err = %v, want ErrNoToken before anything is stored", err)
+ }
+
+ want := Token{
+ AccessToken: "access-1",
+ RefreshToken: "refresh-1",
+ TokenType: "bearer",
+ ExpiresAt: fixedNow.Add(time.Hour).UTC(),
+ }
+ if err := store.Save(want); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+ got, err := store.Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if got.AccessToken != want.AccessToken || got.RefreshToken != want.RefreshToken {
+ t.Errorf("round trip changed the token: %+v", got)
+ }
+ if !got.ExpiresAt.Equal(want.ExpiresAt) {
+ t.Errorf("ExpiresAt = %s, want %s", got.ExpiresAt, want.ExpiresAt)
+ }
+}
+
+// The file holds a live credential, so nobody else on the machine may read it.
+func TestStoredTokenIsPrivate(t *testing.T) {
+ store := tempStore(t)
+ if err := store.Save(Token{AccessToken: "a"}); err != nil {
+ t.Fatal(err)
+ }
+
+ info, err := os.Stat(store.Path())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode := info.Mode().Perm(); mode != 0o600 {
+ t.Errorf("token file mode is %o, want 600", mode)
+ }
+ dir, err := os.Stat(filepath.Dir(store.Path()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode := dir.Mode().Perm(); mode != 0o700 {
+ t.Errorf("token directory mode is %o, want 700", mode)
+ }
+}
+
+func TestSaveReplaces(t *testing.T) {
+ store := tempStore(t)
+ if err := store.Save(Token{AccessToken: "first", RefreshToken: "r1"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Save(Token{AccessToken: "second"}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := store.Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.AccessToken != "second" || got.RefreshToken != "" {
+ t.Errorf("got %+v, want the second token entirely", got)
+ }
+
+ // The temporary file used for the atomic write must not survive.
+ entries, err := os.ReadDir(filepath.Dir(store.Path()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 {
+ t.Errorf("directory holds %d files, want just the token", len(entries))
+ }
+}
+
+func TestDeleteIsIdempotent(t *testing.T) {
+ store := tempStore(t)
+ if err := store.Save(Token{AccessToken: "a"}); err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 2; i++ {
+ if err := store.Delete(); err != nil {
+ t.Fatalf("Delete %d: %v", i, err)
+ }
+ }
+ if _, err := store.Load(); !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
+
+func TestLoadRejectsRubbish(t *testing.T) {
+ store := tempStore(t)
+ if err := os.MkdirAll(filepath.Dir(store.Path()), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(store.Path(), []byte("not json"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ _, err := store.Load()
+ if err == nil || errors.Is(err, ErrNoToken) {
+ t.Fatalf("err = %v, want a complaint about the file", err)
+ }
+ if !strings.Contains(err.Error(), "log in again") {
+ t.Errorf("error %q should say how to fix it", err)
+ }
+}
+
+func TestLoadTreatsAnEmptyTokenAsAbsent(t *testing.T) {
+ store := tempStore(t)
+ if err := os.MkdirAll(filepath.Dir(store.Path()), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(store.Path(), []byte(`{"access_token":""}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Load(); !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
+
+// Login runs a one-shot local server; the browser is simulated by fetching the
+// authorization URL's redirect target directly.
+func TestLoginLoopback(t *testing.T) {
+ server := newAuthServer(t)
+ cfg := server.config()
+ cfg.RedirectURL = "http://127.0.0.1:18137/callback"
+
+ token, err := cfg.Login(t.Context(), func(authURL string) {
+ go visitAsBrowser(t, server, authURL, "the-code", "")
+ })
+ if err != nil {
+ t.Fatalf("Login: %v", err)
+ }
+ if token.AccessToken != "access-1" {
+ t.Errorf("token = %+v", token)
+ }
+ if got := server.gotForm.Get("code"); got != "the-code" {
+ t.Errorf("exchanged code %q, want the one from the redirect", got)
+ }
+}
+
+// A redirect carrying somebody else's state must not complete this login.
+func TestLoginRejectsAWrongState(t *testing.T) {
+ server := newAuthServer(t)
+ cfg := server.config()
+ cfg.RedirectURL = "http://127.0.0.1:18138/callback"
+
+ _, err := cfg.Login(t.Context(), func(authURL string) {
+ go visitAsBrowser(t, server, authURL, "the-code", "not-the-state")
+ })
+ if err == nil || !strings.Contains(err.Error(), "wrong state") {
+ t.Fatalf("err = %v, want a state mismatch", err)
+ }
+}
+
+func TestLoginSurfacesADeclinedConsent(t *testing.T) {
+ server := newAuthServer(t)
+ cfg := server.config()
+ cfg.RedirectURL = "http://127.0.0.1:18139/callback"
+
+ _, err := cfg.Login(t.Context(), func(authURL string) {
+ go func() {
+ redirect := redirectTargetOf(t, authURL)
+ get(t, server, redirect+"?error=access_denied&error_description=Nei")
+ }()
+ })
+ var oauthErr *Error
+ if !errors.As(err, &oauthErr) || !oauthErr.AccessDenied() {
+ t.Fatalf("err = %v, want an access_denied *Error", err)
+ }
+}
+
+// A redirect URL that is not on this machine cannot be listened on; say so
+// rather than failing obscurely.
+func TestLoginRefusesARemoteRedirect(t *testing.T) {
+ cfg := newAuthServer(t).config()
+ cfg.RedirectURL = "https://example.com/callback"
+
+ _, err := cfg.Login(t.Context(), func(string) {})
+ if err == nil || !strings.Contains(err.Error(), "--manual") {
+ t.Fatalf("err = %v, want it to point at --manual", err)
+ }
+}
+
+func TestManualLogin(t *testing.T) {
+ server := newAuthServer(t)
+ cfg := server.config()
+
+ authURL, state, err := cfg.ManualAuthCodeURL()
+ if err != nil {
+ t.Fatalf("ManualAuthCodeURL: %v", err)
+ }
+ if !strings.Contains(authURL, url.QueryEscape(state)) {
+ t.Errorf("the URL %q should carry the state", authURL)
+ }
+
+ pasted := "http://localhost:8137/callback?code=pasted-code&state=" + url.QueryEscape(state)
+ token, err := cfg.CompleteManual(t.Context(), state, " "+pasted+" ")
+ if err != nil {
+ t.Fatalf("CompleteManual: %v", err)
+ }
+ if token.AccessToken != "access-1" {
+ t.Errorf("token = %+v", token)
+ }
+ if got := server.gotForm.Get("code"); got != "pasted-code" {
+ t.Errorf("exchanged %q", got)
+ }
+}
+
+// Some browsers show the code instead of navigating, so a bare code is taken.
+func TestManualLoginAcceptsABareCode(t *testing.T) {
+ server := newAuthServer(t)
+ token, err := server.config().CompleteManual(t.Context(), "state-1", "just-the-code")
+ if err != nil {
+ t.Fatalf("CompleteManual: %v", err)
+ }
+ if token.AccessToken != "access-1" {
+ t.Errorf("token = %+v", token)
+ }
+ if got := server.gotForm.Get("code"); got != "just-the-code" {
+ t.Errorf("exchanged %q, want the bare code", got)
+ }
+}
+
+func TestManualLoginRejects(t *testing.T) {
+ cfg := newAuthServer(t).config()
+ cases := []struct{ name, pasted, want string }{
+ {"nothing", " ", "nothing pasted"},
+ {"another attempt", "http://localhost:8137/callback?code=c&state=other", "different login attempt"},
+ {"an error redirect", "http://localhost:8137/callback?error=access_denied", "access_denied"},
+ {"no code", "http://localhost:8137/callback?foo=bar", "no authorization code"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := cfg.CompleteManual(t.Context(), "state-1", tc.pasted)
+ if err == nil {
+ t.Fatalf("CompleteManual accepted %s", tc.name)
+ }
+ if !strings.Contains(err.Error(), tc.want) {
+ t.Errorf("error %q should mention %q", err, tc.want)
+ }
+ })
+ }
+}
+
+// visitAsBrowser plays the part of the browser: it reads the redirect target
+// and state out of the authorization URL and calls back with a code.
+func visitAsBrowser(t *testing.T, server *authServer, authURL, code, state string) {
+ t.Helper()
+ parsed, err := url.Parse(authURL)
+ if err != nil {
+ t.Errorf("parse %q: %v", authURL, err)
+ return
+ }
+ query := parsed.Query()
+ if state == "" {
+ state = query.Get("state")
+ }
+ get(t, server, query.Get("redirect_uri")+
+ "?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state))
+}
+
+func redirectTargetOf(t *testing.T, authURL string) string {
+ t.Helper()
+ parsed, err := url.Parse(authURL)
+ if err != nil {
+ t.Fatalf("parse %q: %v", authURL, err)
+ }
+ return parsed.Query().Get("redirect_uri")
+}
+
+func get(t *testing.T, server *authServer, target string) {
+ t.Helper()
+ resp, err := server.server.Client().Get(target)
+ if err != nil {
+ t.Errorf("GET %s: %v", target, err)
+ return
+ }
+ resp.Body.Close()
+}
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
+}