package user import ( "net/http" "net/http/httptest" "strconv" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/controllers" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/compass" "github.com/golang-jwt/jwt/v5" ) const ( loggedOutBody = `{"message":"Logged out"}` unauthorizedRaw = `{"error":true,"message":"Unauthorized"}` blacklistedBody = `{"error":"Could not refresh token","msg":"The token has been blacklisted"}` userTokensPath = "/_user/api/v1/tokens" userLogoutPath = "/_user/api/v1/logout" userRefreshPath = "/_user/api/v1/refresh" ) // TestLogoutEndsTheSession: POST /logout ends the session however the token // travels (the bearer header, or only the token or auth_token cookie the // jwt guard also reads), so the next request with the same token or cookie // is 401 on the jwt.auth routes, at /fetch and at /refresh. It clears the // session cookies with the attributes the token cookie is written with, and // the revocation lasts for the token's refresh window: a logged-out token is // not refreshable after its access lifetime, and an expired but still // refreshable token is revoked by a logout too. func TestLogoutEndsTheSession(t *testing.T) { e := newAdminEnv(t) t.Run("bearer", func(t *testing.T) { token := e.sessionLogin(t, "logout-bearer") if code := e.guarded(t, token, ""); code != http.StatusOK { t.Fatalf("jwt.auth before logout = %d", code) } rec := e.call(t, http.MethodPost, userLogoutPath, token, nil) assertLoggedOut(t, rec) e.assertSessionEnded(t, token) }) for _, name := range []string{"token", "auth_token"} { t.Run("cookie "+name, func(t *testing.T) { token := e.sessionLogin(t, "logout-"+name) if code := e.guarded(t, token, name); code != http.StatusOK { t.Fatalf("jwt.auth with the %s cookie before logout = %d", name, code) } rec := e.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": name + "=" + token}) assertLoggedOut(t, rec) e.assertSessionEnded(t, token) want := e.cookieConfig(t) assertForgets(t, rec, want, want.Name) if name != want.Name { assertForgets(t, rec, want, name) } }) } t.Run("bearer logout forgets the token cookie too", func(t *testing.T) { token := e.sessionLogin(t, "logout-bearer-cookie") rec := e.call(t, http.MethodPost, userLogoutPath, token, nil) assertLoggedOut(t, rec) want := e.cookieConfig(t) assertForgets(t, rec, want, want.Name) for _, c := range rec.Result().Cookies() { if c.Name == "auth_token" { t.Fatalf("a request without the auth_token cookie got a forget cookie for it: %s", c) } } }) t.Run("refused token still forgets the cookie", func(t *testing.T) { rec := e.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": "token=not-a-token"}) if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw { t.Fatalf("logout with a bad cookie = %d %s", rec.Code, rec.Body.String()) } assertForgets(t, rec, e.cookieConfig(t), "token") missing := e.call(t, http.MethodPost, userLogoutPath, "", nil) if missing.Code != http.StatusUnauthorized || missing.Body.String() != unauthorizedRaw { t.Fatalf("logout without a token = %d %s", missing.Code, missing.Body.String()) } }) t.Run("revocation outlives the access token", func(t *testing.T) { token := e.sessionLogin(t, "logout-window") claims := parseToken(t, token) jti, _ := claims["jti"].(string) iat, _ := claims.GetIssuedAt() exp, _ := claims.GetExpirationTime() assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, token, nil)) var expiresAt time.Time if err := e.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&expiresAt).Error; err != nil { t.Fatal(err) } // The later of exp and iat plus the refresh window, plus a minute. want := iat.Add(controllers.RefreshTTL(e.app)).Add(time.Minute) if d := expiresAt.Sub(want); d < -time.Second || d > time.Second { t.Fatalf("blacklist row expires at %v, want %v (refresh window plus a minute)", expiresAt, want) } // The sweep that runs once the access token has expired: the same // predicate as bouncer.PostgresBlacklist.Sweep, limited to this jti // so other tests' rows stay. if err := e.gdb.Exec(`DELETE FROM jwt_blacklist WHERE jti = ? AND expires_at < ?`, jti, exp.Add(2*time.Minute)).Error; err != nil { t.Fatal(err) } rec := e.refresh(t, token) if rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody { t.Fatalf("refresh of a logged-out token after its access lifetime = %d %s", rec.Code, rec.Body.String()) } }) t.Run("expired but refreshable token", func(t *testing.T) { user := e.seedUser(t, "logout-expired", nil) iat := time.Now().Add(-2 * time.Hour) token := userAudienceToken(t, strconv.FormatUint(uint64(user.ID), 10), "logout-expired-"+adminStamp(), iat, iat.Add(time.Hour)) rec := e.call(t, http.MethodPost, userLogoutPath, token, nil) assertLoggedOut(t, rec) if refreshed := e.refresh(t, token); refreshed.Code != http.StatusUnauthorized || refreshed.Body.String() != blacklistedBody { t.Fatalf("refresh after logging out an expired token = %d %s", refreshed.Code, refreshed.Body.String()) } }) t.Run("every token the request carries", func(t *testing.T) { bearerToken := e.sessionLogin(t, "logout-both-bearer") cookieToken := e.sessionLogin(t, "logout-both-cookie") rec := e.send(t, http.MethodPost, userLogoutPath, bearerToken, nil, "application/json", map[string]string{"Cookie": "token=" + cookieToken}) assertLoggedOut(t, rec) e.assertSessionEnded(t, bearerToken) e.assertSessionEnded(t, cookieToken) }) t.Run("refused tokens", func(t *testing.T) { user := e.seedUser(t, "logout-refused", nil) sub := strconv.FormatUint(uint64(user.ID), 10) closedAt := time.Now().Add(-controllers.RefreshTTL(e.app) - time.Hour) for name, token := range map[string]string{ // The refresh window closed: nothing left to revoke. "closed window": userAudienceToken(t, sub, "logout-closed-"+adminStamp(), closedAt, closedAt.Add(time.Hour)), // No audience (a PHP token) is accepted only while unexpired. "expired without audience": craftToken(t, sub, "logout-legacy-old-"+adminStamp(), time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour)), // No jti: it cannot be blacklisted. "no jti": userAudienceToken(t, sub, "", time.Now(), time.Now().Add(time.Hour)), // A user that does not exist. "unknown user": userAudienceToken(t, "999999999", "logout-ghost-"+adminStamp(), time.Now(), time.Now().Add(time.Hour)), } { rec := e.call(t, http.MethodPost, userLogoutPath, token, nil) if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw { t.Fatalf("%s: logout = %d %s", name, rec.Code, rec.Body.String()) } assertForgets(t, rec, e.cookieConfig(t), "token") } legacy := craftToken(t, sub, "logout-legacy-"+adminStamp(), time.Now().Add(-time.Minute), time.Now().Add(time.Hour)) if code := e.guarded(t, legacy, ""); code != http.StatusOK { t.Fatalf("jwt.auth with an unexpired token without audience = %d", code) } assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, legacy, nil)) if code := e.guarded(t, legacy, ""); code != http.StatusUnauthorized { t.Fatalf("jwt.auth after logging out a token without audience = %d", code) } }) t.Run("configured cookie attributes and refresh window", func(t *testing.T) { custom := newAdminEnv(t, func(c *compass.Config) { for key, value := range map[string]any{ "auth.cookie.domain": "example.com", "auth.cookie.same_site": "strict", "auth.cookie.path": "/", "golem15.user.jwt.refresh_ttl": 120, } { if err := c.Set(key, value); err != nil { t.Fatal(err) } } }) token := custom.sessionLogin(t, "logout-custom") rec := custom.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": "token=" + token}) assertLoggedOut(t, rec) want := custom.cookieConfig(t) if want.Domain != "example.com" || want.SameSite != http.SameSiteStrictMode { t.Fatalf("config not applied: %+v", want) } assertForgets(t, rec, want, "token") custom.assertSessionEnded(t, token) // Logout keeps the row for the window /refresh uses. claims := parseToken(t, token) jti, _ := claims["jti"].(string) iat, _ := claims.GetIssuedAt() var expiresAt time.Time if err := custom.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&expiresAt).Error; err != nil { t.Fatal(err) } if window := controllers.RefreshTTL(custom.app); window != 120*time.Minute { t.Fatalf("refresh window = %v", window) } if d := expiresAt.Sub(iat.Add(121 * time.Minute)); d < -time.Second || d > time.Second { t.Fatalf("blacklist row expires at %v, want iat plus 121 minutes", expiresAt) } }) } // sessionLogin seeds a frontend user, signs in through POST /login and // returns the session token. func (e *adminEnv) sessionLogin(t *testing.T, label string) string { t.Helper() user := e.seedUser(t, label, nil) code, token := e.userLogin(t, user.Email, "correct-horse") if code != http.StatusOK || token == "" { t.Fatalf("login %s = %d", label, code) } return token } // guarded calls GET /tokens, a jwt.auth route, with token as the bearer // header (cookie "") or only in the named cookie, and returns the status. func (e *adminEnv) guarded(t *testing.T, token, cookie string) int { t.Helper() if cookie == "" { return e.call(t, http.MethodGet, userTokensPath, token, nil).Code } return e.send(t, http.MethodGet, userTokensPath, "", nil, "application/json", map[string]string{"Cookie": cookie + "=" + token}).Code } // assertSessionEnded: token no longer authenticates, through any carrier, // and cannot be refreshed. func (e *adminEnv) assertSessionEnded(t *testing.T, token string) { t.Helper() for _, carrier := range []string{"", "token", "auth_token"} { if code := e.guarded(t, token, carrier); code != http.StatusUnauthorized { t.Fatalf("jwt.auth after logout (cookie %q) = %d", carrier, code) } } if code := e.fetchCode(t, token); code != http.StatusUnauthorized { t.Fatalf("fetch after logout = %d", code) } if rec := e.refresh(t, token); rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody { t.Fatalf("refresh after logout = %d %s", rec.Code, rec.Body.String()) } } func (e *adminEnv) cookieConfig(t *testing.T) bouncer.CookieConfig { t.Helper() cfg, err := bouncer.LoadCookieConfig(e.app.Config) if err != nil { t.Fatal(err) } return cfg } func assertLoggedOut(t *testing.T, rec *httptest.ResponseRecorder) { t.Helper() if rec.Code != http.StatusOK || rec.Body.String() != loggedOutBody { t.Fatalf("logout = %d %s", rec.Code, rec.Body.String()) } } // assertForgets: the response deletes the cookie name with the path, // domain, SameSite, Secure and HttpOnly of the token cookie cfg writes. func assertForgets(t *testing.T, rec *httptest.ResponseRecorder, cfg bouncer.CookieConfig, name string) { t.Helper() set := cfg.TokenCookie("x", time.Hour) for _, c := range rec.Result().Cookies() { if c.Name != name { continue } if c.Value != "" || c.MaxAge >= 0 || c.Path != set.Path || c.Domain != set.Domain || c.SameSite != set.SameSite || c.Secure != set.Secure || c.HttpOnly != set.HttpOnly { t.Fatalf("forget cookie %s does not match the token cookie %s", c, set) } return } t.Fatalf("no forget cookie for %s in %v", name, rec.Result().Header.Values("Set-Cookie")) } // userAudienceToken signs a session token the way bouncer.Mint does (user // audience, prv), with a chosen iat and exp. func userAudienceToken(t *testing.T, sub, jti string, iat, exp time.Time) string { t.Helper() claims := jwt.MapClaims{ "sub": sub, "aud": []string{bouncer.AudienceUser}, "iss": "http://example.com/_user/api/v1/login", "iat": iat.Unix(), "nbf": iat.Unix(), "exp": exp.Unix(), "jti": jti, "prv": prvHash, } signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret)) if err != nil { t.Fatal(err) } return signed }