From c0a01ac65bb492640b169274614e0d8022f5076d Mon Sep 17 00:00:00 2001 From: "Simen A. W. Olsen" Date: Sat, 17 Oct 2026 14:19:09 +0200 Subject: Fiken API client and OAuth2 for Go 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. --- fikenauth/oauth_test.go | 264 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 fikenauth/oauth_test.go (limited to 'fikenauth/oauth_test.go') 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") + } +} -- cgit v1.3.1