From 7c11c2dc29d1f4703c4d663dbf182f1e9e474cc9 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 22:11:03 +0200 Subject: [PATCH] feat(api/v2): port refresh-token endpoint to /api/v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/routes/api/v2/auth_refresh.go | 75 +++++++++++++++++++++ pkg/routes/api/v2/errors.go | 12 ++++ pkg/routes/routes.go | 1 + pkg/webtests/huma_auth_refresh_test.go | 92 ++++++++++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 pkg/routes/api/v2/auth_refresh.go create mode 100644 pkg/webtests/huma_auth_refresh_test.go 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) + }) +}