Files
sm-user-plugin/refresh_cookie_test.go
Jakub Zych 846087db45 fix: a /refresh carried by the auth.cookie session cookie rewrites that cookie, so an HttpOnly session survives the rotation
8e4b863 answered a cookie-carried refresh in the body only and blacklisted the old jti, so an HttpOnly cookie (golem15.sociallogin token_cookie) kept a dead token. Now the cookie named auth.cookie.name gets the rotated token and the body is {}; bearer and auth_token refreshes are unchanged, and refusals write no cookie.
2026-10-22 23:39:18 +02:00

262 lines
10 KiB
Go

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
}