From 62e7f9d8b9ca64f39a8e2aff1851bd5ee04ad016 Mon Sep 17 00:00:00 2001 From: Dan Berg <61684965+wg-daniel@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:42:13 +0200 Subject: [PATCH] Fix/OIDC return url base path (#735) * fix: build OAuth return URL from the live location, not the Vite base On base_path deployments, OAuth/OIDC login failed with a 400 "invalid return URL" before the user ever reached the IdP. LoginView and the router derived the app's runtime URL mount from import.meta.env.BASE_URL, which is Vite's build-time *asset* base. Since #711 set `base: './'` to fix relative asset loading (#710), BASE_URL is "./", so `base_path: /wg` produced the return URL https://host/wg./#/login. isValidReturnUrl() requires /app, so every external-auth user on a base_path deployment was locked out. Derive the return URL from window.location instead: the app is mounted at {base_path}/app/ in production and at / under `npm run dev`, so the live document location is the only reliable source. Likewise drop the explicit base from createWebHashHistory(), which then defaults to `location.pathname + location.search` -- correct in every deployment. This leaves the relative asset base from #711 untouched, so #710 stays fixed, and it removes the last two readers of import.meta.env.BASE_URL under frontend/src so the asset base can no longer affect routing. Fixes #719 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dan Berg * fix: log rejected OAuth return URLs The isValidReturnUrl() rejection was the only failure branch in handleOauthInitiateGet without a slog call, and the 400 response body reveals neither what was received nor what was expected. That is what made #719 hard to diagnose. Log both at Debug level, matching the neighbouring branches. The response body is unchanged -- the URL is not echoed to the client. Also note on the base-path test that the URL shape it pins is produced by externalLogin() in frontend/src/views/LoginView.vue, so a future frontend change has a breadcrumb back to the contract. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Dan Berg --------- Signed-off-by: Dan Berg Co-authored-by: Claude Opus 5 (1M context) --- frontend/src/router/index.js | 6 +++--- frontend/src/views/LoginView.vue | 5 ++++- internal/app/api/v0/handlers/endpoint_authentication.go | 2 ++ .../v0/handlers/endpoint_authentication_basepath_test.go | 3 +++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 6b5728b..42c8935 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -6,10 +6,10 @@ import {authStore} from '@/stores/auth' import {securityStore} from '@/stores/security' import {notify} from "@kyvg/vue3-notification"; -const routerBase = `${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}` - const router = createRouter({ - history: createWebHashHistory(routerBase), + // No base argument: createWebHashHistory() defaults to location.pathname + location.search, + // which is correct for /app/, {web.base_path}/app/ and the dev server at /. + history: createWebHashHistory(), routes: [ { path: '/', diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 3ac6dad..d6b1ff0 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -83,7 +83,10 @@ const externalLogin = function (provider) { console.log("Performing external login for provider", provider.Identifier); loggingIn.value = true; console.log(router.currentRoute.value); - const currentUrl = new URL(`${WGPORTAL_BASE_PATH || ''}${import.meta.env.BASE_URL || '/'}`, window.location.origin); + // Derive the return URL from the live document location, never from the build-time asset base + // (import.meta.env.BASE_URL): the app is mounted at {web.base_path}/app/ in production and at / + // under `npm run dev`, so window.location is the only reliable source. + const currentUrl = new URL(window.location.href); currentUrl.hash = router.currentRoute.value.fullPath; let currentUri = currentUrl.toString(); let redirectUrl = `${WGPORTAL_BACKEND_BASE_URL}${provider.ProviderUrl}`; diff --git a/internal/app/api/v0/handlers/endpoint_authentication.go b/internal/app/api/v0/handlers/endpoint_authentication.go index 3b3f872..6ffbb96 100644 --- a/internal/app/api/v0/handlers/endpoint_authentication.go +++ b/internal/app/api/v0/handlers/endpoint_authentication.go @@ -208,6 +208,8 @@ func (e AuthEndpoint) handleOauthInitiateGet() http.HandlerFunc { if returnTo != "" { if !e.isValidReturnUrl(returnTo) { + slog.Debug("rejected invalid oauth return URL", + "provider", provider, "returnTo", returnTo, "expectedPrefix", e.frontendUrl("")) respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "invalid return URL"}) return diff --git a/internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go b/internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go index 872dc83..d5c773b 100644 --- a/internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go +++ b/internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go @@ -37,6 +37,9 @@ func newBasePathAuthEndpoint(session Session) AuthEndpoint { } } +// TestAuthEndpointIsValidReturnUrlRequiresBasePathApp pins the return URL shape that the frontend must +// produce. The accepted values are generated by externalLogin() in frontend/src/views/LoginView.vue +// from window.location - keep the two in sync when changing either side. func TestAuthEndpointIsValidReturnUrlRequiresBasePathApp(t *testing.T) { ep := newBasePathAuthEndpoint(&testSession{})