package user import ( "encoding/json" "net/http" "net/http/httptest" "slices" "strings" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/compass" ) // opaque500Body is the plugin's opaque 500 answer (wire.WriteOpaque500). const opaque500Body = `{"error":true,"message":"Internal server error"}` // TestRefreshRewritesAuthCookie pins the cookie-carried POST /refresh: when // the token came from the auth.cookie cookie with no bearer header, that // cookie is rewritten with the rotated token and the body is empty, whoever // wrote the cookie; bearer and other-cookie refreshes stay body-only; // refusals touch no cookie; and a broken auth.cookie block fails a cookie // refresh before it rotates anything. func TestRefreshRewritesAuthCookie(t *testing.T) { e := newAdminEnv(t) t.Run("token cookie rotates", func(t *testing.T) { old := e.sessionLogin(t, "refresh-cookie-rotates") rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil) if rec.Code != http.StatusOK { t.Fatalf("refresh with the token cookie = %d %s", rec.Code, rec.Body.String()) } assertBodyKeys(t, rec) next := assertSessionCookie(t, rec, e.cookieConfig(t), 20160*60) if next == old { t.Fatal("the rewritten cookie carries the old token") } sub := parseToken(t, old)["sub"] if got := parseToken(t, next)["sub"]; got != sub { t.Fatalf("rotated token names %v, old one %v", got, sub) } fetch := e.cookieSend(t, http.MethodGet, userFetchPath, "token", next, nil) if fetch.Code != http.StatusOK { t.Fatalf("fetch with the rewritten cookie = %d %s", fetch.Code, fetch.Body.String()) } if got := fetchedID(t, fetch); got != sub { t.Fatalf("fetch served user %q, token names %v", got, sub) } if code := e.guarded(t, next, "token"); code != http.StatusOK { t.Fatalf("jwt.auth with the rewritten cookie = %d", code) } if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "token", old, nil); rec.Code != http.StatusUnauthorized { t.Fatalf("fetch with the rotated-away token cookie = %d", rec.Code) } }) t.Run("auth_token cookie stays body-only", func(t *testing.T) { old := e.sessionLogin(t, "refresh-cookie-auth-token") rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "auth_token", old, nil) if rec.Code != http.StatusOK { t.Fatalf("refresh with the auth_token cookie = %d %s", rec.Code, rec.Body.String()) } body := assertBodyKeys(t, rec, "token") if next, _ := body["token"].(string); next == "" || next == old { t.Fatalf("refresh answered %s", rec.Body.String()) } assertNoCookie(t, rec) }) t.Run("bearer wins over the cookie", func(t *testing.T) { a := e.sessionLogin(t, "refresh-cookie-bearer-a") b := e.sessionLogin(t, "refresh-cookie-bearer-b") rec := e.send(t, http.MethodPost, userRefreshPath, a, nil, "application/json", map[string]string{"Cookie": "token=" + b}) if rec.Code != http.StatusOK { t.Fatalf("refresh with bearer A and cookie B = %d %s", rec.Code, rec.Body.String()) } body := assertBodyKeys(t, rec, "token") next, _ := body["token"].(string) if got, want := parseToken(t, next)["sub"], parseToken(t, a)["sub"]; got != want { t.Fatalf("refresh rotated user %v, bearer names %v", got, want) } assertNoCookie(t, rec) if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "token", b, nil); rec.Code != http.StatusOK { t.Fatalf("fetch with cookie B after a bearer refresh = %d", rec.Code) } }) t.Run("bearer only", func(t *testing.T) { token := e.sessionLogin(t, "refresh-cookie-bearer-only") rec := e.refresh(t, token) if rec.Code != http.StatusOK { t.Fatalf("bearer refresh = %d %s", rec.Code, rec.Body.String()) } assertBodyKeys(t, rec, "token") assertNoCookie(t, rec) }) t.Run("configured auth.cookie name", func(t *testing.T) { custom := newAdminEnv(t, func(c *compass.Config) { for key, value := range map[string]any{ "auth.cookie.name": "auth_token", "golem15.user.jwt.refresh_ttl": 60, } { if err := c.Set(key, value); err != nil { t.Fatal(err) } } }) old := custom.sessionLogin(t, "refresh-cookie-custom") rec := custom.cookieSend(t, http.MethodPost, userRefreshPath, "auth_token", old, nil) if rec.Code != http.StatusOK { t.Fatalf("refresh with the auth_token cookie = %d %s", rec.Code, rec.Body.String()) } assertBodyKeys(t, rec) next := assertSessionCookie(t, rec, custom.cookieConfig(t), 3600) if next == old { t.Fatal("the rewritten cookie carries the old token") } if rec := custom.cookieSend(t, http.MethodGet, userFetchPath, "auth_token", next, nil); rec.Code != http.StatusOK { t.Fatalf("fetch with the rewritten auth_token cookie = %d", rec.Code) } other := custom.sessionLogin(t, "refresh-cookie-custom-token") rec = custom.cookieSend(t, http.MethodPost, userRefreshPath, "token", other, nil) if rec.Code != http.StatusOK { t.Fatalf("refresh with the token cookie = %d %s", rec.Code, rec.Body.String()) } assertBodyKeys(t, rec, "token") assertNoCookie(t, rec) }) t.Run("refusals set no cookie", func(t *testing.T) { user := e.seedUser(t, "refresh-cookie-banned", nil) e.seedThrottle(t, user, "", true, nil) banned := userAudienceToken(t, userSub(user), "refresh-cookie-banned-"+adminStamp(), time.Now(), time.Now().Add(time.Hour)) rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", banned, nil) assertRefreshRefused(t, rec) assertNoCookie(t, rec) old := e.sessionLogin(t, "refresh-cookie-twice") if rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil); rec.Code != http.StatusOK { t.Fatalf("first refresh = %d %s", rec.Code, rec.Body.String()) } rec = e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil) if rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody { t.Fatalf("second refresh of the same token = %d %s", rec.Code, rec.Body.String()) } assertNoCookie(t, rec) }) t.Run("broken auth.cookie", func(t *testing.T) { broken := newAdminEnv(t, func(c *compass.Config) { if err := c.Set("auth.cookie.same_site", "sideways"); err != nil { t.Fatal(err) } }) token := broken.sessionLogin(t, "refresh-cookie-broken") rec := broken.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil) if rec.Code != http.StatusInternalServerError || rec.Body.String() != opaque500Body { t.Fatalf("cookie refresh with a broken auth.cookie = %d %s", rec.Code, rec.Body.String()) } assertNoCookie(t, rec) rec = broken.refresh(t, token) if rec.Code != http.StatusOK { t.Fatalf("bearer refresh after the 500 = %d %s", rec.Code, rec.Body.String()) } if next, _ := assertBodyKeys(t, rec, "token")["token"].(string); next == "" { t.Fatalf("bearer refresh answered %s", rec.Body.String()) } }) } // TestSessionTokenCookie is the carrier table of classes.SessionTokenCookie: // it names the session cookie SessionToken read the token from, and "" when // the bearer header supplied it or there is none. func TestSessionTokenCookie(t *testing.T) { cases := []struct { name, auth, cookie, want string }{ {"bearer plus a token cookie", "Bearer A", "token=B", ""}, {"empty bearer plus a token cookie", "Bearer ", "token=B", "token"}, {"basic auth plus a token cookie", "Basic xyz", "token=B", "token"}, {"only auth_token", "", "auth_token=C", "auth_token"}, {"token and auth_token", "", "token=B; auth_token=C", "token"}, {"empty token cookie", "", "token=; auth_token=X", "auth_token"}, {"no credentials", "", "", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest(http.MethodPost, userRefreshPath, nil) if tc.auth != "" { r.Header.Set("Authorization", tc.auth) } if tc.cookie != "" { r.Header.Set("Cookie", tc.cookie) } got := classes.SessionTokenCookie(r) if got != tc.want { t.Fatalf("SessionTokenCookie = %q, want %q", got, tc.want) } token := classes.SessionToken(r) if got != "" { c, err := r.Cookie(got) if err != nil || strings.TrimSpace(c.Value) != token { t.Fatalf("cookie %s does not carry SessionToken %q", got, token) } } else if token != "" && !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer "+token) { t.Fatalf("SessionToken %q did not come from the bearer header", token) } }) } if got := classes.SessionTokenCookie(nil); got != "" { t.Fatalf("SessionTokenCookie(nil) = %q", got) } } // assertSessionCookie requires exactly one cookie, the session cookie cfg // writes with Max-Age maxAge, and returns its value. func assertSessionCookie(t *testing.T, rec *httptest.ResponseRecorder, cfg bouncer.CookieConfig, maxAge int) string { t.Helper() cookies := rec.Result().Cookies() if len(cookies) != 1 { t.Fatalf("want one session cookie, got %v", rec.Result().Header.Values("Set-Cookie")) } c, want := cookies[0], cfg.TokenCookie("x", time.Hour) if c.Name != cfg.Name || c.Value == "" || c.Path != want.Path || c.Domain != want.Domain || c.SameSite != want.SameSite || c.Secure != want.Secure || c.HttpOnly != want.HttpOnly || c.MaxAge != maxAge { t.Fatalf("session cookie %s does not match %s with Max-Age %d", c, want, maxAge) } return c.Value } // assertNoCookie requires a response that sets no cookie. func assertNoCookie(t *testing.T, rec *httptest.ResponseRecorder) { t.Helper() if cookies := rec.Result().Cookies(); len(cookies) != 0 || rec.Header().Get("Set-Cookie") != "" { t.Fatalf("response sets cookies %v", rec.Result().Header.Values("Set-Cookie")) } } // assertBodyKeys decodes the JSON object body and requires exactly keys. func assertBodyKeys(t *testing.T, rec *httptest.ResponseRecorder, keys ...string) map[string]any { t.Helper() var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body == nil { t.Fatalf("body %s is not a JSON object: %v", rec.Body.String(), err) } got := make([]string, 0, len(body)) for key := range body { got = append(got, key) } slices.Sort(got) want := slices.Clone(keys) slices.Sort(want) if !slices.Equal(got, want) { t.Fatalf("body keys %v, want %v: %s", got, want, rec.Body.String()) } return body }