feat(api/v2): port refresh-token endpoint to /api/v2

POST /api/v2/user/token/refresh reads the HttpOnly refresh cookie, rotates
the session, mints a new JWT, and sets the new cookie — reusing the shared
auth.RefreshSession core (no v1 change) and the #2912 cookie helpers /
authTokenBody response shape. The cookie is set via the unwrapped echo ctx,
not the OpenAPI spec.

translateDomainError now maps *echo.HTTPError (which RefreshSession returns
for missing/invalid/expired/replayed tokens) so those land as the right
status instead of a 500. Completes the v1→v2 REST migration.
This commit is contained in:
kolaente 2026-06-17 22:11:03 +02:00
parent 5b7924b1f6
commit 0d4396cd8b
4 changed files with 180 additions and 0 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

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

View File

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

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
})
}