fix(M10): bump pending-TTL to 5min + repair MD3 contrast on pair page

TTL: 2 min was te kort voor handmatig curl-paste-confirm-testen — gebruiker
zag 'Pairing verlopen' voor hij kon bevestigen. Bumpt naar 5 min (gelijk aan
approved-TTL): nog steeds tight voor security, ruim voor menselijke reactie.
- app/api/auth/pair/start/route.ts: PENDING_TTL_MS 120s → 300s
- lib/auth/pair-cookie.ts: MAX_AGE_SECONDS 120 → 300
- __tests__/api/pair-start.test.ts: maxAge en expires_at-window meegegroeid

Kleuren: bevestigingspagina gebruikte bg-destructive/10 + text-destructive-
foreground — beide lichte kleuren, te weinig contrast. Vervangen door MD3
container-tokens (zelfde patroon als components/auth/auth-form.tsx):
- error-state: bg-error-container + text-error-container-foreground + border-l-4 border-error
- approved-state: bg-success-container + foreground + accent-border
- cancelled-state: bg-surface-container-high + neutral foreground

Quality gates: lint 0 errors, tsc clean, vitest 139/139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 23:15:02 +02:00
parent 5c4ee150ea
commit c48e30df1f
4 changed files with 14 additions and 12 deletions

View file

@ -62,10 +62,10 @@ describe('POST /api/auth/pair/start', () => {
expect(arg.desktop_token_hash).toMatch(/^[a-f0-9]{64}$/) expect(arg.desktop_token_hash).toMatch(/^[a-f0-9]{64}$/)
expect(arg.secret_hash).not.toBe(body.mobileSecret) expect(arg.secret_hash).not.toBe(body.mobileSecret)
expect(arg.status).toBe('pending') expect(arg.status).toBe('pending')
// expires_at ~120s in toekomst // expires_at ~5 min in toekomst
const dt = new Date(arg.expires_at).getTime() - Date.now() const dt = new Date(arg.expires_at).getTime() - Date.now()
expect(dt).toBeGreaterThan(115_000) expect(dt).toBeGreaterThan(295_000)
expect(dt).toBeLessThan(125_000) expect(dt).toBeLessThan(305_000)
}) })
it('zet HttpOnly Path-scoped s4m_pair cookie met Max-Age 120', async () => { it('zet HttpOnly Path-scoped s4m_pair cookie met Max-Age 120', async () => {
@ -78,7 +78,7 @@ describe('POST /api/auth/pair/start', () => {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
path: '/api/auth/pair', path: '/api/auth/pair',
maxAge: 120, maxAge: 300,
}) })
}) })

View file

@ -110,18 +110,18 @@ export function PairConfirmation() {
if (state.kind === 'invalid') { if (state.kind === 'invalid') {
return ( return (
<div className="bg-destructive/10 text-destructive-foreground mt-6 rounded-md p-4"> <div className="bg-error-container text-error-container-foreground border-error mt-6 rounded-md border-l-4 p-4">
<p className="font-medium">Kan deze QR-code niet gebruiken</p> <p className="font-medium">Kan deze QR-code niet gebruiken</p>
<p className="text-sm">{state.error}</p> <p className="text-sm opacity-90">{state.error}</p>
</div> </div>
) )
} }
if (state.kind === 'approved') { if (state.kind === 'approved') {
return ( return (
<div className="bg-primary/10 mt-6 rounded-md p-4"> <div className="bg-success-container text-success-container-foreground border-success mt-6 rounded-md border-l-4 p-4">
<p className="font-medium">Klaar je kunt deze tab sluiten.</p> <p className="font-medium">Klaar je kunt deze tab sluiten.</p>
<p className="text-muted-foreground text-sm"> <p className="text-sm opacity-90">
Het apparaat met de QR-code is nu ingelogd als <strong>{state.username}</strong>. Het apparaat met de QR-code is nu ingelogd als <strong>{state.username}</strong>.
</p> </p>
</div> </div>
@ -130,9 +130,11 @@ export function PairConfirmation() {
if (state.kind === 'cancelled') { if (state.kind === 'cancelled') {
return ( return (
<div className="bg-muted text-muted-foreground mt-6 rounded-md p-4"> <div className="bg-surface-container-high text-foreground mt-6 rounded-md p-4">
<p className="font-medium">Geannuleerd</p> <p className="font-medium">Geannuleerd</p>
<p className="text-sm">Er is geen sessie aangemaakt op het andere apparaat.</p> <p className="text-muted-foreground text-sm">
Er is geen sessie aangemaakt op het andere apparaat.
</p>
</div> </div>
) )
} }

View file

@ -20,7 +20,7 @@ import { checkRateLimit } from '@/lib/rate-limit'
export const runtime = 'nodejs' export const runtime = 'nodejs'
const PENDING_TTL_MS = 2 * 60 * 1000 // 2 min — komt overeen met s4m_pair Max-Age const PENDING_TTL_MS = 5 * 60 * 1000 // 5 min — komt overeen met s4m_pair Max-Age
const UA_MAX = 255 // matcht VarChar(255) op login_pairings.desktop_ua const UA_MAX = 255 // matcht VarChar(255) op login_pairings.desktop_ua
const IP_MAX = 45 // matcht VarChar(45) — IPv6 max length const IP_MAX = 45 // matcht VarChar(45) — IPv6 max length

View file

@ -8,7 +8,7 @@
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
const COOKIE_NAME = 's4m_pair' const COOKIE_NAME = 's4m_pair'
const MAX_AGE_SECONDS = 120 // gelijk aan pending-TTL van LoginPairing const MAX_AGE_SECONDS = 300 // gelijk aan pending-TTL van LoginPairing (5 min)
const COOKIE_PATH = '/api/auth/pair' const COOKIE_PATH = '/api/auth/pair'
export async function setPairCookie(desktopToken: string): Promise<void> { export async function setPairCookie(desktopToken: string): Promise<void> {