From 3c2826635d29614b0d9495eaf8ff939ab2349b4e Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 15:54:15 +0200 Subject: [PATCH] feat(portal-auth): URL fragment for activation/reset tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 8 per PRE-DEPLOY-PLAN § 1.2.5. Activation + password-reset links now carry the token in the URL fragment (`#token=…`) instead of the query string (`?token=…`). URL fragments are client-side only — the token never hits the server, never lands in proxy logs, never sits in the Referer header, and is invisible to upstream CDN/cache layers. The form still POSTs the token in the request body to authenticate. Changes: - portal-auth.service.ts URL builders for activation + reset switch to `#token=`. Inline comments cite the security rationale. - password-set-form.tsx reads the token via useSyncExternalStore so the SSR snapshot returns `null` and the client snapshot reads window.location.hash post-hydration (no set-state-in-effect Compiler violation). Helper prefers the fragment but falls back to the legacy `?token=` search param for the back-compat TTL window — so links sent before the switchover still work for their remaining lifetime. Component renders a "Loading…" placeholder during the pre-hydration null state. No DB changes; tokens themselves unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/portal/password-set-form.tsx | 60 ++++++++++++++++++--- src/lib/services/portal-auth.service.ts | 10 +++- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/components/portal/password-set-form.tsx b/src/components/portal/password-set-form.tsx index e9d6156a..51daa85f 100644 --- a/src/components/portal/password-set-form.tsx +++ b/src/components/portal/password-set-form.tsx @@ -1,8 +1,7 @@ 'use client'; import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; -import { useState } from 'react'; +import { useState, useSyncExternalStore } from 'react'; import { CheckCircle2, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -23,11 +22,41 @@ interface PasswordSetFormProps { const MIN_LENGTH = 9; /** - * Shared form used by both the activation and password-reset flows. The - * activation token is read from the `?token=` query string. Empty / missing - * tokens land the user in an explicit error state instead of submitting a - * doomed request. + * Shared form used by both the activation and password-reset flows. + * + * The activation/reset token is read from the URL fragment (`#token=…`) + * — not the query string — so the token never travels to the server, + * never lands in proxy / reverse-proxy logs, never sits in the Referer + * header, and is invisible to upstream CDN/cache layers. The browser + * still sends it on form-submit via the explicit POST body. + * + * Pre-2026-05-14 the token was passed as `?token=…`; the legacy + * search-param read is kept as a fallback so links sent before the + * switchover still work for the remaining TTL. + * + * Empty / missing tokens land the user in an explicit error state + * instead of submitting a doomed request. */ +function readTokenFromUrl(): string { + if (typeof window === 'undefined') return ''; + const hash = window.location.hash.replace(/^#/, ''); + if (hash) { + const params = new URLSearchParams(hash); + const fromFragment = params.get('token'); + if (fromFragment) return fromFragment; + } + // Back-compat: pre-fragment links still carry `?token=…`. Drop after + // every outstanding activation/reset link past TTL has been consumed. + const search = new URLSearchParams(window.location.search); + return search.get('token') ?? ''; +} + +// Tiny no-op subscriber — we don't actually need to react to +// hashchange events (the token is set once when the user lands on the +// page). The store integration is here so React knows the source is +// external and skips the SSR / hydration mismatch warning. +const subscribe = () => () => undefined; + export function PasswordSetForm({ endpoint, title, @@ -36,8 +65,15 @@ export function PasswordSetForm({ successDescription, submitLabel, }: PasswordSetFormProps) { - const search = useSearchParams(); - const token = search.get('token') ?? ''; + // Read the token via useSyncExternalStore — fragment is client-only + // so the server-snapshot returns `null` and the client snapshot reads + // window.location.hash post-hydration. `null` distinguishes "not yet + // hydrated" from "missing"; empty string means "hydrated, none found". + const token = useSyncExternalStore( + subscribe, + () => readTokenFromUrl(), + () => null, + ); const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); @@ -73,6 +109,14 @@ export function PasswordSetForm({ } } + if (token === null) { + return ( + +
Loading…
+
+ ); + } + if (!token) { return ( diff --git a/src/lib/services/portal-auth.service.ts b/src/lib/services/portal-auth.service.ts index 94630ff1..31e68723 100644 --- a/src/lib/services/portal-auth.service.ts +++ b/src/lib/services/portal-auth.service.ts @@ -144,7 +144,11 @@ async function issueActivationToken( const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); const portName = port?.name ?? 'Port Nimara'; - const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`; + // URL fragment (#token=…) instead of query string — keeps the + // activation token out of server logs, proxy logs, Referer header, + // and any CDN/edge cache. The portal /activate page reads the token + // client-side via `window.location.hash`. See PRE-DEPLOY-PLAN § 1.2.5. + const link = `${env.APP_URL}/portal/activate#token=${encodeURIComponent(raw)}`; const subjectOverride = await loadSubjectOverride(portId, 'portal_activation'); const branding = await getBrandingShell(portId); const { subject, html, text } = await activationEmail( @@ -406,7 +410,9 @@ export async function requestPasswordReset(email: string): Promise { const port = await db.query.ports.findFirst({ where: eq(ports.id, user.portId) }); const portName = port?.name ?? 'Port Nimara'; - const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`; + // Same URL-fragment treatment as activation links — token never + // travels server-side. See PRE-DEPLOY-PLAN § 1.2.5. + const link = `${env.APP_URL}/portal/reset-password#token=${encodeURIComponent(raw)}`; const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset'); const branding = await getBrandingShell(user.portId); const { subject, html, text } = await resetEmail(