fix(guest): move guest token from URL query to sessionStorage
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 3m38s
CI / E2E browser tests (pull_request) Failing after 1m16s

The guest token (the customer's only credential) was passed as
?token=... in the URL query string on the payment page. Query strings
land in browser history, nginx/reverse-proxy access logs, and can leak
via Referer headers.

The magic-link landing /gast-order/:token is inherently URL-based
(accepted Notion/Stripe pattern), but the payment page does not need to
expose the token in the URL.

Changes:
  - GuestCheckoutPage.vue: store token in sessionStorage before
    navigating to payment page; remove token from query string
  - GuestOrderPage.vue: replace RouterLink with click handler that
    stores token in sessionStorage before navigating to payment;
    add useRouter import
  - GuestPaymentRedirect.vue: read token from sessionStorage with
    fallback to query.token for backward compatibility with existing
    magic links
  - GuestCheckoutPage.spec.ts: assert token is NOT in query, IS in
    sessionStorage after navigation
  - GuestPaymentRedirect.spec.ts: set sessionStorage in mount helper;
    clear sessionStorage in beforeEach

sessionStorage persists across page refreshes within the same tab, so
the payment page still survives refresh. If the user opens the payment
URL in a new tab (from history), sessionStorage is lost — but the
canonical re-entry point is the magic link /gast-order/:token, from
which they can navigate to payment again.

Closes #21
This commit is contained in:
Hermes Agent 2026-07-18 11:41:58 +00:00
parent 48c2a50c5d
commit 120251867c
5 changed files with 36 additions and 15 deletions

View file

@ -158,8 +158,9 @@ describe('GuestCheckoutPage', () => {
await vi.waitFor(() => { await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('guest-payment') expect(router.currentRoute.value.name).toBe('guest-payment')
expect(router.currentRoute.value.params.orderId).toBe('order-123') expect(router.currentRoute.value.params.orderId).toBe('order-123')
expect(router.currentRoute.value.query.token).toBe('token-abc') expect(router.currentRoute.value.query.token).toBeUndefined()
expect(router.currentRoute.value.query.plate).toBe('ABC123') expect(router.currentRoute.value.query.plate).toBe('ABC123')
expect(sessionStorage.getItem('guestToken')).toBe('token-abc')
}) })
}) })

View file

@ -67,13 +67,20 @@ async function mountPage(
token = 'token-abc', token = 'token-abc',
plate = 'ABC123', plate = 'ABC123',
) { ) {
// Store token in sessionStorage as GuestCheckoutPage and GuestOrderPage do
if (token) {
sessionStorage.setItem('guestToken', token)
} else {
sessionStorage.removeItem('guestToken')
}
const pinia = createPinia() const pinia = createPinia()
setActivePinia(pinia) setActivePinia(pinia)
const router = createTestRouter() const router = createTestRouter()
await router.push({ await router.push({
name: 'guest-payment', name: 'guest-payment',
params: { orderId }, params: { orderId },
query: { token, plate }, query: { plate },
}) })
await router.isReady() await router.isReady()
@ -99,6 +106,7 @@ function setupDefaultMocks() {
describe('GuestPaymentRedirect', () => { describe('GuestPaymentRedirect', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
sessionStorage.clear()
setupDefaultMocks() setupDefaultMocks()
}) })

View file

@ -37,11 +37,14 @@ async function handleSubmit() {
letterText.value, letterText.value,
email.value.trim(), email.value.trim(),
) )
// Token rides in the query string so the payment page survives refresh. // Store the guest token in sessionStorage so it does not leak via the URL
// query string (browser history, access logs, Referer). sessionStorage
// persists across page refreshes within the same tab.
sessionStorage.setItem('guestToken', order.guestToken)
await router.push({ await router.push({
name: 'guest-payment', name: 'guest-payment',
params: { orderId: order.id }, params: { orderId: order.id },
query: { token: order.guestToken, plate: order.plate }, query: { plate: order.plate },
}) })
} catch { } catch {
errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.' errorMessage.value = 'Kunde inte skapa beställningen. Försök igen senare.'

View file

@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { fetchGuestOrder, type GuestOrder } from '@/api/guestOrders' import { fetchGuestOrder, type GuestOrder } from '@/api/guestOrders'
const route = useRoute() const route = useRoute()
const router = useRouter()
const token = route.params.token as string const token = route.params.token as string
const order = ref<GuestOrder | null>(null) const order = ref<GuestOrder | null>(null)
@ -45,6 +46,18 @@ onMounted(async () => {
loading.value = false loading.value = false
} }
}) })
function goToPayment() {
if (!order.value) return
// Store the guest token in sessionStorage so it does not leak via the URL
// query string on the payment page.
sessionStorage.setItem('guestToken', token)
router.push({
name: 'guest-payment',
params: { orderId: order.value.id },
query: { plate: order.value.plate },
})
}
</script> </script>
<template> <template>
@ -78,15 +91,7 @@ onMounted(async () => {
</div> </div>
<p v-if="order.status === 'pending_payment'" class="guest-order__hint"> <p v-if="order.status === 'pending_payment'" class="guest-order__hint">
<RouterLink <a href="#" @click.prevent="goToPayment"> till betalningssidan </a>
:to="{
name: 'guest-payment',
params: { orderId: order.id },
query: { token, plate: order.plate },
}"
>
till betalningssidan
</RouterLink>
</p> </p>
<div class="guest-order__letter"> <div class="guest-order__letter">

View file

@ -9,7 +9,11 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const orderId = route.params.orderId as string const orderId = route.params.orderId as string
const token = (route.query.token as string) || '' // Read token from sessionStorage (set by the checkout/order pages) so it
// does not leak via the URL query string. Fall back to query.token for
// backward compatibility with any existing magic links.
const token =
sessionStorage.getItem('guestToken') || (route.query.token as string) || ''
const plate = ref((route.query.plate as string) || '') const plate = ref((route.query.plate as string) || '')
const swishNumber = ref('') const swishNumber = ref('')