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") } }