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:
parent
20d8d23474
commit
7c11c2dc29
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue