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 | |
| 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')
| -rw-r--r-- | fikenauth/login.go | 217 | ||||
| -rw-r--r-- | fikenauth/oauth.go | 246 | ||||
| -rw-r--r-- | fikenauth/oauth_test.go | 264 | ||||
| -rw-r--r-- | fikenauth/source.go | 125 | ||||
| -rw-r--r-- | fikenauth/source_test.go | 172 | ||||
| -rw-r--r-- | fikenauth/store_test.go | 298 | ||||
| -rw-r--r-- | fikenauth/token.go | 143 |
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 +} |
