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.
262 lines
10 KiB
Go
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
|
|
}
|