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.
This commit is contained in:
kolaente 2026-03-26 16:31:37 +01:00 committed by kolaente
parent 39e16653aa
commit 7a258f67c7
2 changed files with 98 additions and 74 deletions

View File

@ -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
}

View File

@ -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.