fix vue and oauth redirects under web base path (#683)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled

This commit is contained in:
Dan Berg
2026-05-19 21:52:54 +02:00
committed by GitHub
parent c2b4a5d03c
commit 0cf04d07e0
4 changed files with 198 additions and 47 deletions

View File

@@ -6,8 +6,10 @@ import {authStore} from '@/stores/auth'
import {securityStore} from '@/stores/security' import {securityStore} from '@/stores/security'
import {notify} from "@kyvg/vue3-notification"; import {notify} from "@kyvg/vue3-notification";
const routerBase = `${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}`
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(routerBase),
routes: [ routes: [
{ {
path: '/', path: '/',

View File

@@ -83,7 +83,9 @@ const externalLogin = function (provider) {
console.log("Performing external login for provider", provider.Identifier); console.log("Performing external login for provider", provider.Identifier);
loggingIn.value = true; loggingIn.value = true;
console.log(router.currentRoute.value); console.log(router.currentRoute.value);
let currentUri = window.location.origin + "/#" + router.currentRoute.value.fullPath; const currentUrl = new URL(`${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}`, window.location.origin);
currentUrl.hash = router.currentRoute.value.fullPath;
let currentUri = currentUrl.toString();
let redirectUrl = `${WGPORTAL_BACKEND_BASE_URL}${provider.ProviderUrl}`; let redirectUrl = `${WGPORTAL_BACKEND_BASE_URL}${provider.ProviderUrl}`;
redirectUrl += "?redirect=true"; redirectUrl += "?redirect=true";
redirectUrl += "&return=" + encodeURIComponent(currentUri); redirectUrl += "&return=" + encodeURIComponent(currentUri);

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-pkgz/routegroup" "github.com/go-pkgz/routegroup"
@@ -201,9 +202,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
provider := request.Path(r, "provider") provider := request.Path(r, "provider")
var returnUrl *url.URL var returnUrl *url.URL
var returnParams string redirectToReturn := func(loginState string) {
redirectToReturn := func() { respond.Redirect(w, r, http.StatusFound, e.returnUrlWithLoginState(returnUrl, loginState))
respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams)
} }
if returnTo != "" { if returnTo != "" {
@@ -212,21 +212,18 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"}) model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"})
return return
} }
if u, err := url.Parse(returnTo); err == nil { u, err := url.Parse(returnTo)
returnUrl = u if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"})
return
} }
queryParams := returnUrl.Query() returnUrl = u
queryParams.Set("wgLoginState", "err") // by default, we set the state to error
returnUrl.RawQuery = "" // remove potential query params
returnParams = queryParams.Encode()
} }
if currentSession.LoggedIn { if currentSession.LoggedIn {
if autoRedirect && e.isValidReturnUrl(returnTo) { if autoRedirect && returnUrl != nil {
queryParams := returnUrl.Query() redirectToReturn("success")
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else { } else {
respond.JSON(w, http.StatusBadRequest, respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "already logged in"}) model.Error{Code: http.StatusBadRequest, Message: "already logged in"})
@@ -238,8 +235,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc {
if err != nil { if err != nil {
slog.Debug("failed to create oauth auth code URL", slog.Debug("failed to create oauth auth code URL",
"provider", provider, "error", err) "provider", provider, "error", err)
if autoRedirect && e.isValidReturnUrl(returnTo) { if autoRedirect && returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusInternalServerError, respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()}) model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
@@ -278,27 +275,19 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
currentSession := e.session.GetData(r.Context()) currentSession := e.session.GetData(r.Context())
var returnUrl *url.URL var returnUrl *url.URL
var returnParams string redirectToReturn := func(loginState string) {
redirectToReturn := func() { respond.Redirect(w, r, http.StatusFound, e.returnUrlWithLoginState(returnUrl, loginState))
respond.Redirect(w, r, http.StatusFound, returnUrl.String()+"?"+returnParams)
} }
if currentSession.OauthReturnTo != "" { if currentSession.OauthReturnTo != "" && e.isValidReturnUrl(currentSession.OauthReturnTo) {
if u, err := url.Parse(currentSession.OauthReturnTo); err == nil { if u, err := url.Parse(currentSession.OauthReturnTo); err == nil {
returnUrl = u returnUrl = u
} }
queryParams := returnUrl.Query()
queryParams.Set("wgLoginState", "err") // by default, we set the state to error
returnUrl.RawQuery = "" // remove potential query params
returnParams = queryParams.Encode()
} }
if currentSession.LoggedIn { if currentSession.LoggedIn {
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
queryParams := returnUrl.Query() redirectToReturn("success")
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else { } else {
respond.JSON(w, http.StatusBadRequest, model.Error{Message: "already logged in"}) respond.JSON(w, http.StatusBadRequest, model.Error{Message: "already logged in"})
} }
@@ -312,8 +301,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
if provider != currentSession.OauthProvider { if provider != currentSession.OauthProvider {
slog.Debug("invalid oauth provider in callback", slog.Debug("invalid oauth provider in callback",
"expected", currentSession.OauthProvider, "got", provider, "state", oauthState) "expected", currentSession.OauthProvider, "got", provider, "state", oauthState)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusBadRequest, respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid oauth provider"}) model.Error{Code: http.StatusBadRequest, Message: "invalid oauth provider"})
@@ -323,8 +312,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
if oauthState != currentSession.OauthState { if oauthState != currentSession.OauthState {
slog.Debug("invalid oauth state in callback", slog.Debug("invalid oauth state in callback",
"expected", currentSession.OauthState, "got", oauthState, "provider", provider) "expected", currentSession.OauthState, "got", oauthState, "provider", provider)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusBadRequest, respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "invalid oauth state"}) model.Error{Code: http.StatusBadRequest, Message: "invalid oauth state"})
@@ -339,8 +328,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
if err != nil { if err != nil {
slog.Debug("failed to process oauth code", slog.Debug("failed to process oauth code",
"provider", provider, "state", oauthState, "error", err) "provider", provider, "state", oauthState, "error", err)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
redirectToReturn() redirectToReturn("err")
} else { } else {
respond.JSON(w, http.StatusUnauthorized, respond.JSON(w, http.StatusUnauthorized,
model.Error{Code: http.StatusUnauthorized, Message: err.Error()}) model.Error{Code: http.StatusUnauthorized, Message: err.Error()})
@@ -350,11 +339,8 @@ func (e AuthEndpoint) handleOauthCallbackGet() http.HandlerFunc {
e.setAuthenticatedUser(r, user, provider, idTokenHint) e.setAuthenticatedUser(r, user, provider, idTokenHint)
if returnUrl != nil && e.isValidReturnUrl(returnUrl.String()) { if returnUrl != nil {
queryParams := returnUrl.Query() redirectToReturn("success")
queryParams.Set("wgLoginState", "success")
returnParams = queryParams.Encode()
redirectToReturn()
} else { } else {
respond.JSON(w, http.StatusOK, user) respond.JSON(w, http.StatusOK, user)
} }
@@ -444,11 +430,7 @@ func (e AuthEndpoint) handleLogoutPost() http.HandlerFunc {
return return
} }
postLogoutRedirectUri := e.cfg.Web.ExternalUrl postLogoutRedirectUri := e.frontendUrl("/login")
if e.cfg.Web.BasePath != "" {
postLogoutRedirectUri += e.cfg.Web.BasePath
}
postLogoutRedirectUri += "/#/login"
var redirectUrl *string var redirectUrl *string
if currentSession.OauthProvider != "" { if currentSession.OauthProvider != "" {
@@ -479,9 +461,60 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
return false return false
} }
if e.cfg.Web.BasePath != "" {
expectedPath := e.cfg.Web.BasePath + "/app"
if returnUrlParsed.Path != expectedPath && !strings.HasPrefix(returnUrlParsed.Path, expectedPath+"/") {
return false
}
}
return true return true
} }
func (e AuthEndpoint) frontendUrl(route string) string {
frontendUrl := e.cfg.Web.ExternalUrl + e.cfg.Web.BasePath + "/app/"
if route != "" {
frontendUrl += "#" + route
}
return frontendUrl
}
func (e AuthEndpoint) returnUrlWithLoginState(returnUrl *url.URL, loginState string) string {
if returnUrl == nil {
frontendURL, err := url.Parse(e.frontendUrl("/login"))
if err != nil {
return e.frontendUrl("/login")
}
returnUrl = frontendURL
}
redirectUrl := *returnUrl
if redirectUrl.Fragment != "" {
fragmentPath := redirectUrl.Fragment
fragmentQuery := ""
if queryStart := strings.Index(fragmentPath, "?"); queryStart >= 0 {
fragmentQuery = fragmentPath[queryStart+1:]
fragmentPath = fragmentPath[:queryStart]
}
queryParams, err := url.ParseQuery(fragmentQuery)
if err != nil {
queryParams = url.Values{}
}
queryParams.Set("wgLoginState", loginState)
redirectUrl.Fragment = fragmentPath + "?" + queryParams.Encode()
return redirectUrl.String()
}
queryParams := redirectUrl.Query()
queryParams.Set("wgLoginState", loginState)
redirectUrl.RawQuery = queryParams.Encode()
return redirectUrl.String()
}
// handleWebAuthnCredentialsGet returns a gorm Handler function. // handleWebAuthnCredentialsGet returns a gorm Handler function.
// //
// @ID auth_handleWebAuthnCredentialsGet // @ID auth_handleWebAuthnCredentialsGet

View File

@@ -0,0 +1,114 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/h44z/wg-portal/internal/config"
)
type testSession struct {
data SessionData
}
func (s *testSession) SetData(_ context.Context, val SessionData) {
s.data = val
}
func (s *testSession) GetData(_ context.Context) SessionData {
return s.data
}
func (s *testSession) DestroyData(_ context.Context) {
s.data = SessionData{}
}
func newBasePathAuthEndpoint(session Session) AuthEndpoint {
return AuthEndpoint{
cfg: &config.Config{
Web: config.WebConfig{
ExternalUrl: "https://wg.example.com",
BasePath: "/subpath",
},
},
session: session,
}
}
func TestAuthEndpointIsValidReturnUrlRequiresBasePathApp(t *testing.T) {
ep := newBasePathAuthEndpoint(&testSession{})
valid := []string{
"https://wg.example.com/subpath/app/#/login",
"https://wg.example.com/subpath/app/#/login?all=true",
"https://wg.example.com/subpath/app/?beforeHash=true#/login",
}
for _, returnURL := range valid {
if !ep.isValidReturnUrl(returnURL) {
t.Fatalf("expected return URL to be valid: %s", returnURL)
}
}
invalid := []string{
"https://wg.example.com/#/login",
"https://wg.example.com/subpath/#/login",
"https://other.example.com/subpath/app/#/login",
}
for _, returnURL := range invalid {
if ep.isValidReturnUrl(returnURL) {
t.Fatalf("expected return URL to be invalid: %s", returnURL)
}
}
}
func TestAuthEndpointOauthCallbackRedirectsToBasePathHashRoute(t *testing.T) {
session := &testSession{data: SessionData{
LoggedIn: true,
OauthReturnTo: "https://wg.example.com/subpath/app/#/login",
}}
ep := newBasePathAuthEndpoint(session)
req := httptest.NewRequest(http.MethodGet, "/api/v0/auth/login/google/callback", nil)
req.SetPathValue("provider", "google")
res := httptest.NewRecorder()
ep.handleOauthCallbackGet().ServeHTTP(res, req)
if res.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, res.Code)
}
if got, want := res.Header().Get("Location"), "https://wg.example.com/subpath/app/#/login?wgLoginState=success"; got != want {
t.Fatalf("expected redirect %q, got %q", want, got)
}
}
func TestAuthEndpointReturnUrlWithLoginStatePreservesHashQuery(t *testing.T) {
session := &testSession{data: SessionData{
LoggedIn: true,
OauthReturnTo: "https://wg.example.com/subpath/app/#/login?all=true",
}}
ep := newBasePathAuthEndpoint(session)
req := httptest.NewRequest(http.MethodGet, "/api/v0/auth/login/google/callback", nil)
req.SetPathValue("provider", "google")
res := httptest.NewRecorder()
ep.handleOauthCallbackGet().ServeHTTP(res, req)
if res.Code != http.StatusFound {
t.Fatalf("expected status %d, got %d", http.StatusFound, res.Code)
}
if got, want := res.Header().Get("Location"), "https://wg.example.com/subpath/app/#/login?all=true&wgLoginState=success"; got != want {
t.Fatalf("expected redirect %q, got %q", want, got)
}
}
func TestAuthEndpointFrontendUrlUsesBasePathAppMount(t *testing.T) {
ep := newBasePathAuthEndpoint(&testSession{})
if got, want := ep.frontendUrl("/login"), "https://wg.example.com/subpath/app/#/login"; got != want {
t.Fatalf("expected frontend URL %q, got %q", want, got)
}
}