summaryrefslogtreecommitdiffstats
path: root/fikenauth/oauth_test.go
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/oauth_test.go
downloadfiken-0.1.0.tar.gz
fiken-0.1.0.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/oauth_test.go')
-rw-r--r--fikenauth/oauth_test.go264
1 files changed, 264 insertions, 0 deletions
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")
+ }
+}