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
This commit is contained in:
kolaente 2026-06-19 16:55:56 +02:00
parent adf031128e
commit a9355fc247
3 changed files with 40 additions and 7 deletions

View File

@ -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='

View File

@ -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,
}
}

View File

@ -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