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