package authn import ( "bytes" "context" "encoding/hex" "encoding/json" "fmt" "net" "net/http" "net/http/httptest" "os" "reflect" "strconv" "strings" "testing" "time" "github.com/featurebasedb/featurebase/v3/logger" "github.com/golang-jwt/jwt" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) func NewTestAuth(t *testing.T) *Auth { t.Helper() var ( ClientID = "e9088663-eb08-41d7-8f65-efb5f54bbb71" ClientSecret = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" AuthorizeURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize" TokenURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/token" GroupEndpointURL = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?$count=true" LogoutURL = "https://login.microsoftonline.com/common/oauth2/v2.0/logout" Scopes = []string{"https://graph.microsoft.com/.default", "offline_access"} Key = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" configuredIPs = []string{} ) a, err := NewAuth( logger.NewStandardLogger(os.Stdout), "http://localhost:10101/", Scopes, AuthorizeURL, TokenURL, GroupEndpointURL, LogoutURL, ClientID, ClientSecret, Key, configuredIPs, ) if err != nil { t.Fatalf("building auth object%s", err) } return a } func TestSetGRPCMetadata(t *testing.T) { a := NewTestAuth(t) for name, md := range map[string]metadata.MD{ "empty": {}, "something": {"cookie": []string{a.accessCookieName + "=something"}}, "somethingElse": {"cookie": []string{ a.accessCookieName + "=something", a.refreshCookieName + "=something", }}, "otherCookies": {"cookie": []string{a.accessCookieName + "=something", "blah=blah"}}, } { t.Run(name, func(t *testing.T) { ogCookies := md["cookie"] ctx := grpc.NewContextWithServerTransportStream( metadata.NewIncomingContext(context.TODO(), md, ), NewServerTransportStream(), ) md, ok := metadata.FromIncomingContext(ctx) if !ok { t.Fatalf("expected ok, got: %v", ok) } ctx, err := a.SetGRPCMetadata(ctx, md, "accesstoken!", "refreshtoken!") if err != nil { t.Fatalf("expected no errors, got: %v", err) } if err := grpc.SendHeader(ctx, md); err != nil { t.Fatalf("expected no errors, got: %v", err) } md, ok = metadata.FromIncomingContext(ctx) if !ok { t.Fatalf("expected ok, got: %v", ok) } c, ok := md["cookie"] if !ok { t.Fatalf("expected ok, got: %v", ok) } var accessCookie, refreshCookie string for _, cookie := range c { if strings.HasPrefix(cookie, a.accessCookieName) { accessCookie = cookie } else if strings.HasPrefix(cookie, a.refreshCookieName) { refreshCookie = cookie } if refreshCookie != "" && accessCookie != "" { break } } exp := a.accessCookieName + "=accesstoken!" if accessCookie != exp { t.Fatalf("expected '%v', got '%v'", exp, accessCookie) } exp = a.refreshCookieName + "=refreshtoken!" if refreshCookie != exp { t.Fatalf("expected '%v', got '%v'", exp, refreshCookie) } for _, cookie := range c { if strings.HasPrefix(cookie, a.accessCookieName) || strings.HasPrefix(cookie, a.refreshCookieName) { continue } found := false for _, ogCookie := range ogCookies { if cookie == ogCookie { found = true break } } if !found { t.Fatal("SetGRPCMetadata did not maintain the previous cookie list") } } }) } } func TestAuth(t *testing.T) { a := NewTestAuth(t) t.Run("SetCookie", func(t *testing.T) { w := httptest.NewRecorder() err := a.SetCookie(w, "access", "refresh", time.Now().Add(time.Hour)) if err != nil { t.Fatalf("expected no errors, got: %v", err) } if w.Result().Cookies()[0].Value == "" { t.Errorf("expected something, got empty string") } if got, want := w.Result().Cookies()[0].Path, "/"; got != want { t.Fatalf("path=%s, want %s", got, want) } }) t.Run("KeyLength", func(t *testing.T) { _, err := NewAuth( logger.NewStandardLogger(os.Stdout), "http://localhost:10101/", []string{"https://graph.microsoft.com/.default", "offline_access"}, "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize", "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/token", "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?$count=true", "https://login.microsoftonline.com/common/oauth2/v2.0/logout", "e9088663-eb08-41d7-8f65-efb5f54bbb71", "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", "DEADBEEFD", []string{}, ) if err == nil || !strings.Contains(err.Error(), "decoding secret key") { t.Fatalf("expected error decoding secret key got: %v", err) } }) t.Run("GetSecretKey", func(t *testing.T) { want, _ := hex.DecodeString("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") if got := a.SecretKey(); !bytes.Equal(got, want) { t.Fatalf("expected %v, got %v", got, want) } }) } func TestAuthenticate(t *testing.T) { cases := []struct { name string uid string uname string exp int64 refresh bool refreshToken string malformed bool empty bool groups []Group err error }{ { name: "GoodToken", uid: "42", uname: "A. Token", groups: []Group{ { GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "adminGroup", }, }, }, { name: "Malformed", malformed: true, err: fmt.Errorf("parsing auth token: token contains an invalid number of segments"), }, { name: "Empty", empty: true, err: fmt.Errorf("auth token is empty"), }, { name: "ExpiredTokenNoRefresh", uid: "42", uname: "A. Token", groups: []Group{ { GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "adminGroup", }, }, exp: -17764800, err: fmt.Errorf("token is expired: refreshing token: 400 Bad Request"), }, { name: "ExpiredTokenYesRefresh", uid: "42", uname: "A. Token", groups: []Group{ { GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "adminGroup", }, }, refresh: true, refreshToken: "refreshToken", exp: -17764800, }, { name: "ExpiredTokenYesRefreshButError", uid: "42", uname: "A. Token", groups: []Group{ { GroupID: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe", GroupName: "adminGroup", }, }, refresh: true, refreshToken: "blah!!", exp: -17764800, err: fmt.Errorf("token is expired: refreshing token: 403 Forbidden"), }, } for _, test := range cases { t.Run(test.name, func(t *testing.T) { // setup the test a := NewTestAuth(t) token := "" var err error if !test.malformed && !test.empty { tkn := jwt.New(jwt.SigningMethodHS256) claims := tkn.Claims.(jwt.MapClaims) claims["oid"] = test.uid claims["name"] = test.uname if test.exp != 0 { claims["exp"] = float64(test.exp) } token, err = tkn.SignedString(a.SecretKey()) if err != nil { t.Fatalf("unexpected error when signing token %v", err) } } else if !test.empty { token = "asdfasdfasdfasdF" } if len(test.groups) > 0 { a.groupsCache[token] = cachedGroups{time.Now(), test.groups} } if test.refresh { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { t.Fatalf("unexpected error: %v", err) } refresh := r.Form.Get("refresh_token") if refresh != test.refreshToken { t.Fatalf("refresh token not passed properly, expected %v, got %v", test.refreshToken, refresh) return } if refresh != "refreshToken" { http.Error(w, "bad token", http.StatusForbidden) } tkn := jwt.New(jwt.SigningMethodHS256) claims := tkn.Claims.(jwt.MapClaims) claims["oid"] = test.uid claims["name"] = test.uname expiry := float64(time.Now().Add(2 * time.Hour).Unix()) claims["exp"] = expiry fresh, err := tkn.SignedString(a.SecretKey()) if err != nil { t.Fatalf("unexpected error when signing token %v", err) } a.groupsCache[fresh] = cachedGroups{time.Now(), test.groups} fmt.Fprintf(w, `{"access_token": "`+fresh+`", "refresh_token": "blah", "token_type": "bearer", "expires": `+strconv.FormatFloat(expiry, 'f', 0, 64)+` }`) })) defer srv.Close() a.oAuthConfig.Endpoint.TokenURL = srv.URL } // do the actual testing uinfo, err := a.Authenticate(token, test.refreshToken) // okay this part kind of sucks bc we need to check errors and i // dont want to write a whole new test for things that should have // errors just to avoid this mess. errors.Is doesn't work either if (test.err == nil && err != nil) || (test.err != nil && err == nil) { t.Fatalf("expected %v, but got %v", test.err, err) } else if test.err != nil && err != nil { if test.err.Error() != err.Error() { t.Fatalf("expected %v, but got %v", test.err, err) } else { return } } if !reflect.DeepEqual(uinfo.Groups, test.groups) { t.Fatalf("expected %v, got %v", test.groups, uinfo.Groups) } if !reflect.DeepEqual(uinfo.UserID, test.uid) { t.Fatalf("expected %v, got %v", test.uid, uinfo.UserID) } if !reflect.DeepEqual(uinfo.UserName, test.uname) { t.Fatalf("expected %v, got %v", test.uname, uinfo.UserName) } }) } } func TestAuthenticate_CleanCache(t *testing.T) { // this deserves its own test bc it has gross setup required t.Run("should clean", func(t *testing.T) { a := NewTestAuth(t) now := time.Now() a.groupsCache["oldy"] = cachedGroups{now.Add(-24 * time.Hour), []Group{}} a.groupsCache["goldy"] = cachedGroups{now.Add(-4 * time.Hour), []Group{}} a.lastCacheClean = now.Add(-45 * time.Minute) _, _ = a.Authenticate("this doesn't matter", "this doesn't matter?") if a.lastCacheClean.Sub(now) <= time.Nanosecond { t.Fatalf("cache should have been cleaned") } if _, ok := a.groupsCache["oldy"]; ok { t.Errorf("oldy should have been deleted") } if _, ok := a.groupsCache["goldy"]; !ok { t.Errorf("goldy should not have been deleted") } }) t.Run("shouldn't clean", func(t *testing.T) { a := NewTestAuth(t) now := time.Now() a.groupsCache["oldy"] = cachedGroups{now.Add(-24 * time.Hour), []Group{}} a.groupsCache["goldy"] = cachedGroups{now.Add(-4 * time.Hour), []Group{}} a.lastCacheClean = now _, _ = a.Authenticate("this doesn't matter", "this doesn't matter?") if a.lastCacheClean.Sub(now) >= time.Nanosecond { t.Fatalf("cache should not have been cleaned") } if _, ok := a.groupsCache["oldy"]; !ok { t.Errorf("oldy should not have been deleted") } if _, ok := a.groupsCache["goldy"]; !ok { t.Errorf("goldy should not have been deleted") } }) } func TestGetGroups(t *testing.T) { a := NewTestAuth(t) a.groupsCache = map[string]cachedGroups{ "the world is changed": { cacheTime: time.Now(), groups: []Group{ { GroupID: "a han noston ned wilith", GroupName: "I smell it in the air", }, }, }, } srvNext := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := json.Marshal( Groups{ Groups: []Group{ { GroupID: "han mathon ne chae", GroupName: "I feel it in the earth", }, }, }, ) if err != nil { t.Fatalf("unexpected error marshalling groups response: %v", err) } fmt.Fprintf(w, "%s", body) })) defer srvNext.Close() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := json.Marshal( Groups{ NextLink: srvNext.URL, Groups: []Group{ { GroupID: "han mathon ne nen", GroupName: "i feel it in the water", }, }, }, ) if err != nil { t.Fatalf("unexpected error marshalling groups response: %v", err) } fmt.Fprintf(w, "%s", body) })) defer srv.Close() a.groupEndpoint = srv.URL for name, test := range map[string]struct { token string groups []Group }{ "InCache": { token: "the world is changed", groups: []Group{ { GroupID: "a han noston ned wilith", GroupName: "I smell it in the air", }, }, }, "NotInCache": { token: "i smell it in the air", groups: []Group{ { GroupID: "han mathon ne nen", GroupName: "i feel it in the water", }, { GroupID: "han mathon ne chae", GroupName: "I feel it in the earth", }, }, }, } { t.Run(name, func(t *testing.T) { if got, err := a.getGroups(test.token); err != nil || !reflect.DeepEqual(got, test.groups) { t.Errorf("expected %v, nil, got %v, %v", test.groups, got, err) } }) } } func TestDecodeHex(t *testing.T) { t.Run("cantDecode", func(t *testing.T) { _, err := decodeHex("gggg") if err == nil { t.Fatalf("expected err cannot decode slice, got nil") } }) t.Run("tooSmall", func(t *testing.T) { _, err := decodeHex("DEADBEEF") if err == nil { t.Fatalf("expected err wrong length, got nil") } }) t.Run("tooBig", func(t *testing.T) { _, err := decodeHex("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") if err == nil { t.Fatalf("expected err wrong length, got nil") } }) t.Run("justRight", func(t *testing.T) { _, err := decodeHex("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") if err != nil { t.Fatalf("expected nil, got %v", err) } }) } func TestHandlers(t *testing.T) { a := NewTestAuth(t) t.Run("login", func(t *testing.T) { req := httptest.NewRequest("GET", "/login", nil) w := httptest.NewRecorder() a.Login(w, req) resp := w.Result() if resp.StatusCode != http.StatusTemporaryRedirect { t.Fatalf("expected redirect, got %v", resp.StatusCode) } redirect := a.oAuthConfig.AuthCodeURL(a.oAuthConfig.Endpoint.AuthURL) if got, err := resp.Location(); err != nil || got.String() != redirect { t.Fatalf("expected %v, got %v", redirect, got.Path) } }) t.Run("logout", func(t *testing.T) { req := httptest.NewRequest("GET", "/logout", nil) w := httptest.NewRecorder() req.AddCookie( &http.Cookie{ Name: a.accessCookieName, Value: "test", Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Expires: time.Unix(3000000, 0), }, ) req.AddCookie( &http.Cookie{ Name: a.refreshCookieName, Value: "test", Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Expires: time.Unix(3000000, 0), }, ) a.groupsCache["test"] = cachedGroups{} a.Logout(w, req) resp := w.Result() if resp.StatusCode != http.StatusTemporaryRedirect { t.Fatalf("expected redirect, got %v", resp.StatusCode) } redirect := fmt.Sprintf("%s?post_logout_redirect_uri=%s/", a.logoutEndpoint, a.fbURL) if got, err := resp.Location(); err != nil || got.String() != redirect { t.Fatalf("expected %v, got %v", redirect, got.Path) } for _, c := range resp.Cookies() { if c.Name == a.accessCookieName || c.Name == a.refreshCookieName { if c.Value != "" { t.Fatalf("cookie not set to empty value!") } want := time.Unix(0, 0).Unix() got := c.Expires.Unix() if want != got { t.Fatalf("expected %v, got %v", want, got) } } } if _, ok := a.groupsCache["test"]; ok { t.Fatalf("groups not deleted!") } }) t.Run("redirectGood", func(t *testing.T) { req := httptest.NewRequest("GET", "/redirect", nil) w := httptest.NewRecorder() tkn := jwt.New(jwt.SigningMethodHS256) claims := tkn.Claims.(jwt.MapClaims) claims["oid"] = "user id" claims["name"] = "user name" expiresIn := 2 * time.Hour exp := time.Now().Add(expiresIn) expiry := float64(exp.Unix()) claims["exp"] = expiry fresh, err := tkn.SignedString(a.SecretKey()) if err != nil { t.Fatalf("unexpected error when signing token %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body := `{"access_token": "` + fresh + `", "refresh_token": "blah", "expires_in": "` + strconv.Itoa(int(expiresIn.Seconds())) + `"}` w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) w.Write([]byte(body)) })) a.oAuthConfig.Endpoint.TokenURL = srv.URL a.Redirect(w, req) resp := w.Result() if resp.StatusCode != http.StatusTemporaryRedirect { t.Fatalf("expected redirect, got %v", resp.StatusCode) } if got, err := resp.Location(); err != nil || got.String() != "/" { t.Fatalf("expected %v, got %v", "/", got.Path) } cookies := resp.Cookies() for _, c := range cookies { if c.Name == a.accessCookieName && c.Value != fresh { t.Fatalf("expected %v, got %v", exp, c.Value) } else if c.Name == a.refreshCookieName && c.Value != "blah" { t.Fatalf("expected %v, got %v", "blah", c.Value) } } }) t.Run("redirectBad", func(t *testing.T) { req := httptest.NewRequest("GET", "/redirect", nil) w := httptest.NewRecorder() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "Server Error", http.StatusInternalServerError) })) a.oAuthConfig.Endpoint.TokenURL = srv.URL a.Redirect(w, req) resp := w.Result() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("expected BadRequest, got %v", resp.StatusCode) } }) } // This type is used for mocking ServerTransportStreams in tests type ServerTransportStream struct { md metadata.MD method string } func NewServerTransportStream() *ServerTransportStream { return &ServerTransportStream{ md: metadata.MD{}, method: "test", } } func (s *ServerTransportStream) Method() string { return s.method } func (s *ServerTransportStream) SetHeader(md metadata.MD) error { s.md = md return nil } func (s *ServerTransportStream) SendHeader(md metadata.MD) error { _ = md return nil } func (s *ServerTransportStream) SetTrailer(md metadata.MD) error { _ = md return nil } func TestCleanOAuthConfig(t *testing.T) { a := NewTestAuth(t) res := a.CleanOAuthConfig() assertEqual("", res.ClientSecret, t) assertEqual(a.oAuthConfig.ClientID, res.ClientID, t) assertEqual(a.oAuthConfig.RedirectURL, res.RedirectURL, t) assertEqual(a.oAuthConfig.Scopes, res.Scopes, t) assertEqual(a.oAuthConfig.Endpoint, res.Endpoint, t) } func assertEqual(exp, got interface{}, t *testing.T) { if !reflect.DeepEqual(exp, got) { t.Fatalf("expected %v, got %v", exp, got) } } func TestCheckAllowedNetworks(t *testing.T) { tests := []struct { requestIP string configuredIPs []string isAdmin bool }{ { requestIP: "10.0.0.1", configuredIPs: []string{"10.0.0.1"}, isAdmin: true, }, { requestIP: "10.0.0.3", configuredIPs: []string{"10.0.0.1", "10.0.0.2"}, isAdmin: false, }, { requestIP: "10.0.0.2", configuredIPs: []string{"10.0.0.1/30"}, isAdmin: true, }, // it is possible for the client IP to have a port { requestIP: "10.0.0.2:22", configuredIPs: []string{"10.0.0.1/30"}, isAdmin: true, }, { requestIP: "10.1.0.3", configuredIPs: []string{"10.0.0.1/32"}, isAdmin: false, }, { requestIP: "10.0.0.254", configuredIPs: []string{"10.0.0.1/24"}, isAdmin: true, }, } for i, test := range tests { t.Run(fmt.Sprintf("network-%d", i), func(t *testing.T) { a := NewTestAuth(t) if err := a.convertIP(test.configuredIPs); err != nil { t.Fatalf("failed to convert IPs from strings to net.IP: %v", err) } got := a.CheckAllowedNetworks(test.requestIP) if got != test.isAdmin { t.Fatalf("expected %v, got %v", test.isAdmin, got) } }) } } func TestConvertIP(t *testing.T) { tests := []struct { configuredIPs []string convertedIPs []net.IPNet }{ { configuredIPs: []string{"10.0.0.1"}, convertedIPs: []net.IPNet{ {IP: net.ParseIP("10.0.0.1"), Mask: net.CIDRMask(32, 32)}, }, }, { configuredIPs: []string{"10.0.0.1/30"}, convertedIPs: []net.IPNet{ {IP: net.ParseIP("10.0.0.0"), Mask: net.CIDRMask(30, 32)}, }, }, } for i, test := range tests { t.Run(fmt.Sprintf("network-%d", i), func(t *testing.T) { a := NewTestAuth(t) if err := a.convertIP(test.configuredIPs); err != nil { t.Fatalf("failed to convert IPs from strings to net.IP: %v", err) } if len(a.allowedNetworks) != len(test.convertedIPs) { t.Fatalf("expected len of %v networks, got %v", len(test.convertedIPs), len(a.allowedNetworks)) } for i := range a.allowedNetworks { expected, got := test.convertedIPs[i], a.allowedNetworks[i] if got.IP.String() != expected.IP.String() { t.Fatalf("for IP, expected %v, got %v", expected.IP, got.IP) } if got.Mask.String() != expected.Mask.String() { t.Fatalf("for mask, expected %v, got %v", expected.Mask.String(), got.Mask.String()) } } }) } }