diff --git a/pkg/routes/api/v2/auth_refresh.go b/pkg/routes/api/v2/auth_refresh.go
new file mode 100644
index 000000000..d264da7c1
--- /dev/null
+++ b/pkg/routes/api/v2/auth_refresh.go
@@ -0,0 +1,75 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package apiv2
+
+import (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/modules/auth"
+ user2 "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+func init() { AddRouteRegistrar(RegisterRefreshTokenRoutes) }
+
+// RegisterRefreshTokenRoutes wires the refresh-token endpoint. It authenticates
+// via the HttpOnly refresh cookie rather than a JWT, so it is a public operation.
+func RegisterRefreshTokenRoutes(api huma.API) {
+ Register(api, huma.Operation{
+ OperationID: "auth-refresh-token",
+ Summary: "Refresh user token",
+ Description: "Exchanges the refresh-token cookie for a new short-lived JWT. The refresh token is rotated on every call, so the previous one stops working. A new HttpOnly refresh cookie is set on the response.",
+ Method: http.MethodPost,
+ Path: "/user/token/refresh",
+ DefaultStatus: http.StatusOK,
+ Tags: []string{"auth"},
+ Security: publicSecurity,
+ }, authRefreshToken)
+}
+
+func authRefreshToken(ctx context.Context, _ *struct{}) (*authTokenBody, error) {
+ ec := echoContextFromCtx(ctx)
+ if ec == nil {
+ return nil, huma.Error401Unauthorized("No refresh token provided.")
+ }
+
+ cookie, err := ec.Cookie(auth.RefreshTokenCookieName)
+ if err != nil || cookie.Value == "" {
+ return nil, huma.Error401Unauthorized("No refresh token provided.")
+ }
+
+ result, err := auth.RefreshSession(cookie.Value)
+ if err != nil {
+ if user2.IsErrUserStatusError(err) {
+ auth.ClearRefreshTokenCookie(ec)
+ }
+ return nil, translateDomainError(err)
+ }
+
+ cookieMaxAge := int(config.ServiceJWTTTL.GetInt64())
+ if result.IsLongSession {
+ cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64())
+ }
+ auth.SetRefreshTokenCookie(ec, result.NewRefreshToken, cookieMaxAge)
+
+ out := &authTokenBody{CacheControl: "no-store"}
+ out.Body.Token = result.AccessToken
+ return out, nil
+}
diff --git a/pkg/routes/api/v2/errors.go b/pkg/routes/api/v2/errors.go
index 3292b2e2b..24b73a19d 100644
--- a/pkg/routes/api/v2/errors.go
+++ b/pkg/routes/api/v2/errors.go
@@ -28,6 +28,7 @@ import (
"code.vikunja.io/api/pkg/web"
"github.com/danielgtaylor/huma/v2"
+ "github.com/labstack/echo/v5"
)
// authFromCtx retrieves the authed user from a Huma handler context,
@@ -80,6 +81,17 @@ func translateDomainError(err error) error {
}
return se
}
+ // Shared transport-agnostic cores (e.g. auth.RefreshSession) signal HTTP
+ // semantics with *echo.HTTPError. v1 lets echo's error handler render it;
+ // without this it would fall through as a 500 on v2.
+ var he *echo.HTTPError
+ if errors.As(err, &he) {
+ msg := he.Message
+ if msg == "" {
+ msg = http.StatusText(he.Code)
+ }
+ return huma.NewError(he.Code, msg)
+ }
return err
}
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index d20b17f29..a01e12be7 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -361,6 +361,7 @@ var unauthenticatedAPIPaths = map[string]bool{
"/api/v2/shares/:share/auth": true,
"/api/v2/oauth/token": true,
"/api/v2/login": true,
+ "/api/v2/user/token/refresh": true,
"/api/v2/auth/openid/:provider/callback": true,
// Testing endpoints authenticate with the testing token via a custom
diff --git a/pkg/webtests/huma_auth_refresh_test.go b/pkg/webtests/huma_auth_refresh_test.go
new file mode 100644
index 000000000..48fad31c6
--- /dev/null
+++ b/pkg/webtests/huma_auth_refresh_test.go
@@ -0,0 +1,92 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package webtests
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "code.vikunja.io/api/pkg/modules/auth"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// refreshRequest posts to the v2 refresh endpoint with the given refresh-token
+// cookie value (empty value omits the cookie entirely), driving the full
+// echo+Huma stack so cookie reading and Set-Cookie writing are exercised.
+func refreshRequest(e *echo.Echo, refreshToken string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPost, "/api/v2/user/token/refresh", strings.NewReader(""))
+ req.Header.Set("Content-Type", "application/json")
+ if refreshToken != "" {
+ req.AddCookie(&http.Cookie{Name: auth.RefreshTokenCookieName, Value: refreshToken})
+ }
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+// TestHumaRefreshToken ports the v1 refresh-token coverage to /api/v2: a valid
+// cookie yields a new JWT and a rotated HttpOnly cookie, the old token then stops
+// working, and missing/invalid cookies map to the same 401 v1 returns.
+func TestHumaRefreshToken(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ t.Run("valid refresh token", func(t *testing.T) {
+ rec := refreshRequest(e, "testtoken_session1")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ assert.Contains(t, rec.Body.String(), `"token":"`)
+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
+
+ cookie := refreshCookie(rec)
+ require.NotNil(t, cookie, "refresh must set a new refresh-token cookie")
+ assert.NotEmpty(t, cookie.Value)
+ assert.NotEqual(t, "testtoken_session1", cookie.Value, "refresh token must be rotated")
+ assert.True(t, cookie.HttpOnly, "refresh cookie must be HttpOnly")
+ })
+
+ t.Run("rotation invalidates the old token", func(t *testing.T) {
+ // session2 is a separate session so this case does not depend on the
+ // one above. The first refresh succeeds and rotates the token.
+ first := refreshRequest(e, "testtoken_session2")
+ require.Equal(t, http.StatusOK, first.Code, first.Body.String())
+ newCookie := refreshCookie(first)
+ require.NotNil(t, newCookie)
+
+ // Replaying the now-rotated token must fail.
+ replay := refreshRequest(e, "testtoken_session2")
+ assert.Equal(t, http.StatusUnauthorized, replay.Code)
+
+ // The freshly rotated token still works.
+ next := refreshRequest(e, newCookie.Value)
+ assert.Equal(t, http.StatusOK, next.Code, next.Body.String())
+ })
+
+ t.Run("missing cookie", func(t *testing.T) {
+ rec := refreshRequest(e, "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ })
+
+ t.Run("invalid cookie", func(t *testing.T) {
+ rec := refreshRequest(e, "garbage")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ })
+}