From a9355fc24786b44db3bffce9b0a544ae5645fe61 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 19 Jun 2026 16:55:56 +0200 Subject: [PATCH] fix(auth): keep OAuth authorize destination in a copyable login hash (#2654) A native client (desktop/mobile/etc.) opens /oauth/authorize in the OS browser. When the user is unauthenticated, the router previously saved the destination to localStorage and redirected to a bare /login, stripping the authorize URL from the address bar. localStorage is per-browser, so a user who is signed in (or wants to sign in) in a different browser could not copy the URL over and complete the flow. Now, when an unauthenticated user hits oauth.authorize, redirect to /login with the authorize path+query encoded in a #redirect= hash. The hash keeps the URL copyable across browsers while keeping the embedded OAuth params out of server/proxy access logs (a query param would be logged). On arrival at the auth route, the hash is decoded and folded back into the existing localStorage redirect mechanism (saveLastVisited), so redirectIfSaved() completes the journey after any auth method - including the external OIDC round-trip, where localStorage is the only bridge that survives leaving the SPA (populated before the user leaves to the IdP). Scoped strictly to oauth.authorize for all client_ids; every other route keeps its existing localStorage redirect behavior. Fixes #2654 --- frontend/src/constants/redirectHash.ts | 12 +++++++++ frontend/src/router/index.ts | 27 ++++++++++++++++--- .../tests/e2e/user/oauth-authorize.spec.ts | 8 +++--- 3 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 frontend/src/constants/redirectHash.ts diff --git a/frontend/src/constants/redirectHash.ts b/frontend/src/constants/redirectHash.ts new file mode 100644 index 000000000..1dd648e58 --- /dev/null +++ b/frontend/src/constants/redirectHash.ts @@ -0,0 +1,12 @@ +/** + * Hash-fragment prefix used to carry a post-login destination in the URL. + * + * Unlike the localStorage redirect, this lives in the address bar so the URL + * stays copyable between browsers (needed for native OAuth clients that open + * /oauth/authorize, see #2654). It uses the hash – not a query param – so the + * embedded OAuth parameters never reach server or proxy access logs. + * + * Must stay distinct from LINK_SHARE_HASH_PREFIX, which router.beforeEach + * special-cases. + */ +export const REDIRECT_HASH_PREFIX = '#redirect=' diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index cc693cc48..7c16d42cb 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -6,6 +6,7 @@ import {getProjectViewId} from '@/helpers/projectView' import {parseDateOrString} from '@/helpers/time/parseDateOrString' import {getNextWeekDate} from '@/helpers/time/getNextWeekDate' import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash' +import {REDIRECT_HASH_PREFIX} from '@/constants/redirectHash' import {AUTH_ROUTE_NAMES} from '@/constants/authRouteNames' import {PRO_FEATURE} from '@/constants/proFeatures' @@ -30,7 +31,7 @@ const router = createRouter({ } // Scroll to anchor should still work - if (to.hash && !to.hash.startsWith(LINK_SHARE_HASH_PREFIX)) { + if (to.hash && !to.hash.startsWith(LINK_SHARE_HASH_PREFIX) && !to.hash.startsWith(REDIRECT_HASH_PREFIX)) { return {el: to.hash} } @@ -499,15 +500,33 @@ export async function getAuthForRoute(to: RouteLocation, authStore) { } } + // Keep the destination in the address bar (not just per-browser localStorage) so a native + // client's /oauth/authorize URL stays copyable into another browser. Hash, not query, so the + // embedded OAuth params never reach access logs (#2654). + if (to.name === 'oauth.authorize') { + return { + name: 'user.login', + hash: REDIRECT_HASH_PREFIX + encodeURIComponent(to.fullPath), + } + } + + // Fold the hash destination into localStorage: it's the only bridge that survives the + // external OIDC round-trip out of the SPA, so redirectIfSaved() works after any auth method. + if (to.hash.startsWith(REDIRECT_HASH_PREFIX)) { + const destination = decodeURIComponent(to.hash.slice(REDIRECT_HASH_PREFIX.length)) + const resolved = router.resolve(destination) + saveLastVisited(resolved.name as string, resolved.params, resolved.query) + } + // Check if the route the user wants to go to is a route which needs authentication. We use this to // redirect the user after successful login. const isValidUserAppRoute = !AUTH_ROUTE_NAMES.has(to.name as string) && localStorage.getItem('emailConfirmToken') === null - + if (isValidUserAppRoute) { saveLastVisited(to.name as string, to.params, to.query) } - + if (isValidUserAppRoute) { return {name: 'user.login'} } @@ -566,8 +585,8 @@ router.beforeEach(async (to, from) => { const newRoute = await getAuthForRoute(to, authStore) if(newRoute) { return { - ...newRoute, hash: to.hash, + ...newRoute, } } diff --git a/frontend/tests/e2e/user/oauth-authorize.spec.ts b/frontend/tests/e2e/user/oauth-authorize.spec.ts index 908e9ae3f..24228df3f 100644 --- a/frontend/tests/e2e/user/oauth-authorize.spec.ts +++ b/frontend/tests/e2e/user/oauth-authorize.spec.ts @@ -32,10 +32,12 @@ test.describe('OAuth 2.0 Authorization Flow', () => { }) // Navigate to the OAuth authorize frontend route. - // The user is not logged in, so the router guard saves the route - // and redirects to /login. + // The user is not logged in, so the router guard redirects to /login while + // carrying the authorize destination in a copyable #redirect= hash (not a + // query param, to keep the OAuth params out of access logs). await page.goto(`/oauth/authorize?${authorizeParams}`) - await expect(page).toHaveURL(/\/login/) + await expect(page).toHaveURL(/\/login#redirect=/) + expect(decodeURIComponent(new URL(page.url()).hash)).toContain('/oauth/authorize') // Register the response listener BEFORE clicking Login, because after // login redirectIfSaved() navigates back to /oauth/authorize and the