From 7a258f67c7bc248ea2a8573553cef023b9bd3468 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 26 Mar 2026 16:31:37 +0100 Subject: [PATCH] refactor: extract shared RefreshSession helper The cookie-based /user/token/refresh handler had session refresh logic (lookup, expiry check, token rotation, user fetch, JWT generation) that will be reused by the OAuth token endpoint. Extract it into auth.RefreshSession() and rewrite RefreshToken to use it. --- pkg/modules/auth/auth.go | 92 ++++++++++++++++++++++++++++++++++++++ pkg/routes/api/v1/login.go | 80 +++------------------------------ 2 files changed, 98 insertions(+), 74 deletions(-) diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 476416695..dc2ba2c78 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -216,3 +216,95 @@ func CreateUserWithRandomUsername(s *xorm.Session, uu *user.User) (u *user.User, err = models.CreateNewProjectForUser(s, u) return } + +// RefreshResult holds the result of a successful session refresh. +type RefreshResult struct { + AccessToken string + NewRefreshToken string + ExpiresIn int64 + IsLongSession bool + SessionID string +} + +// RefreshSession looks up a session by its raw refresh token, validates it, +// rotates the refresh token, fetches the user, and generates a new JWT. +// It handles its own DB session (open/commit/rollback). +// +// On user status errors (disabled/locked), the session is deleted before +// returning the error so the caller can handle cleanup (e.g. clearing cookies). +func RefreshSession(rawRefreshToken string) (*RefreshResult, error) { + s := db.NewSession() + defer s.Close() + + session, err := models.GetSessionByRefreshToken(s, rawRefreshToken) + if err != nil { + _ = s.Rollback() + if models.IsErrSessionNotFound(err) { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired refresh token.") + } + return nil, err + } + + maxAge := time.Duration(config.ServiceJWTTTL.GetInt64()) * time.Second + if session.IsLongSession { + maxAge = time.Duration(config.ServiceJWTTTLLong.GetInt64()) * time.Second + } + if time.Since(session.LastActive) > maxAge { + if _, err := s.Where("id = ?", session.ID).Delete(&models.Session{}); err != nil { + _ = s.Rollback() + return nil, err + } + if err := s.Commit(); err != nil { + return nil, err + } + return nil, echo.NewHTTPError(http.StatusUnauthorized, "Session expired.") + } + + if err := models.UpdateSessionLastActive(s, session.ID); err != nil { + _ = s.Rollback() + return nil, err + } + + newRawToken, err := models.RotateRefreshToken(s, session) + if err != nil { + _ = s.Rollback() + if models.IsErrSessionNotFound(err) { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "Refresh token already used.") + } + return nil, err + } + + u, err := user.GetUserByID(s, session.UserID) + if err != nil { + if user.IsErrUserStatusError(err) { + if _, delErr := s.Where("id = ?", session.ID).Delete(&models.Session{}); delErr != nil { + _ = s.Rollback() + return nil, delErr + } + if commitErr := s.Commit(); commitErr != nil { + return nil, commitErr + } + return nil, err + } + _ = s.Rollback() + return nil, err + } + + accessToken, err := NewUserJWTAuthtoken(u, session.ID) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := s.Commit(); err != nil { + return nil, err + } + + return &RefreshResult{ + AccessToken: accessToken, + NewRefreshToken: newRawToken, + ExpiresIn: config.ServiceJWTTTLShort.GetInt64(), + IsLongSession: session.IsLongSession, + SessionID: session.ID, + }, nil +} diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index cf27244e5..7856534b9 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -18,7 +18,6 @@ package v1 import ( "net/http" - "time" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" @@ -189,90 +188,23 @@ func RefreshToken(c *echo.Context) (err error) { if err != nil || cookie.Value == "" { return echo.NewHTTPError(http.StatusUnauthorized, "No refresh token provided.") } - rawToken := cookie.Value - s := db.NewSession() - defer s.Close() - - session, err := models.GetSessionByRefreshToken(s, rawToken) + result, err := auth.RefreshSession(cookie.Value) if err != nil { - _ = s.Rollback() - if models.IsErrSessionNotFound(err) { - // Don't clear the cookie here — another tab may have already - // rotated the token, and clearing would overwrite the new cookie. - return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired refresh token.") + if user2.IsErrUserStatusError(err) { + auth.ClearRefreshTokenCookie(c) } return err } - // Check if the session has expired based on its type - maxAge := time.Duration(config.ServiceJWTTTL.GetInt64()) * time.Second - if session.IsLongSession { - maxAge = time.Duration(config.ServiceJWTTTLLong.GetInt64()) * time.Second - } - if time.Since(session.LastActive) > maxAge { - if _, err := s.Where("id = ?", session.ID).Delete(&models.Session{}); err != nil { - _ = s.Rollback() - return err - } - if err := s.Commit(); err != nil { - return err - } - auth.ClearRefreshTokenCookie(c) - return echo.NewHTTPError(http.StatusUnauthorized, "Session expired.") - } - - if err := models.UpdateSessionLastActive(s, session.ID); err != nil { - _ = s.Rollback() - return err - } - - newRawToken, err := models.RotateRefreshToken(s, session) - if err != nil { - _ = s.Rollback() - if models.IsErrSessionNotFound(err) { - // Don't clear the cookie — a concurrent request in another tab - // may have already rotated the token successfully. - return echo.NewHTTPError(http.StatusUnauthorized, "Refresh token already used.") - } - return err - } - - u, err := user2.GetUserWithEmail(s, &user2.User{ID: session.UserID}) - if user2.IsErrUserStatusError(err) { - if _, delErr := s.Where("id = ?", session.ID).Delete(&models.Session{}); delErr != nil { - _ = s.Rollback() - return delErr - } - if commitErr := s.Commit(); commitErr != nil { - return commitErr - } - auth.ClearRefreshTokenCookie(c) - return err - } - if err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() - return err - } - - t, err := auth.NewUserJWTAuthtoken(u, session.ID) - if err != nil { - return err - } - cookieMaxAge := int(config.ServiceJWTTTL.GetInt64()) - if session.IsLongSession { + if result.IsLongSession { cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64()) } - auth.SetRefreshTokenCookie(c, newRawToken, cookieMaxAge) + auth.SetRefreshTokenCookie(c, result.NewRefreshToken, cookieMaxAge) c.Response().Header().Set("Cache-Control", "no-store") - return c.JSON(http.StatusOK, auth.Token{Token: t}) + return c.JSON(http.StatusOK, auth.Token{Token: result.AccessToken}) } // Logout deletes the current session from the server.