fix: login loop and repeated medication notifications

- Fix login loop: secure cookie detection now uses x-forwarded-proto/origin
  headers to correctly identify HTTPS requests through Tailscale Funnel
- Add credentials: include to login/register fetch calls
- Verify session after login/registration before redirecting to prevent race conditions
- Fix repeated medication reminders: isDue() now matches exact minute instead of
  5-minute tolerance window, preventing duplicate notifications when sender runs
  every minute
- Add tests for cookie security and notification scheduling
- Extract isDue() to separate module for better testability
This commit is contained in:
Tony0410
2026-03-15 12:17:42 +00:00
parent f0f674945c
commit 1bb88288f4
12 changed files with 120 additions and 27 deletions

0
! Normal file
View File

View File

@@ -78,7 +78,11 @@ async function handler(req: NextRequest) {
// Create session
const userAgent = req.headers.get('user-agent') || undefined
const token = await createSession(user.id, userAgent, ipAddress)
const cookieConfig = getSessionCookieConfig(token)
const cookieConfig = getSessionCookieConfig(token, {
forwardedProto: req.headers.get('x-forwarded-proto'),
origin: req.headers.get('origin'),
referer: req.headers.get('referer'),
})
const response = NextResponse.json({
user: {

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { getSession, deleteSession, getSessionCookieClearConfig } from '@/lib/auth'
export async function POST() {
export async function POST(req: NextRequest) {
try {
const session = await getSession()
@@ -9,7 +9,11 @@ export async function POST() {
await deleteSession(session.sessionId)
}
const cookieConfig = getSessionCookieClearConfig()
const cookieConfig = getSessionCookieClearConfig({
forwardedProto: req.headers.get('x-forwarded-proto'),
origin: req.headers.get('origin'),
referer: req.headers.get('referer'),
})
const response = NextResponse.json({ message: 'Logged out successfully' })
response.cookies.set(cookieConfig)
@@ -17,7 +21,11 @@ export async function POST() {
} catch (error) {
console.error('Logout error:', error)
// Still clear the cookie even on error
const cookieConfig = getSessionCookieClearConfig()
const cookieConfig = getSessionCookieClearConfig({
forwardedProto: req.headers.get('x-forwarded-proto'),
origin: req.headers.get('origin'),
referer: req.headers.get('referer'),
})
const response = NextResponse.json({ message: 'Logged out' })
response.cookies.set(cookieConfig)
return response

View File

@@ -49,7 +49,11 @@ async function handler(req: NextRequest) {
const userAgent = req.headers.get('user-agent') || undefined
const ipAddress = req.headers.get('x-forwarded-for')?.split(',')[0]
const token = await createSession(user.id, userAgent, ipAddress)
const cookieConfig = getSessionCookieConfig(token)
const cookieConfig = getSessionCookieConfig(token, {
forwardedProto: req.headers.get('x-forwarded-proto'),
origin: req.headers.get('origin'),
referer: req.headers.get('referer'),
})
const response = NextResponse.json({
user,

View File

@@ -24,6 +24,7 @@ function LoginForm() {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
})
@@ -42,6 +43,15 @@ function LoginForm() {
return
}
const sessionResponse = await fetch('/api/auth/me', {
credentials: 'include',
cache: 'no-store',
})
if (!sessionResponse.ok) {
throw new Error('Your session was created but is not available yet. Please try again.')
}
showToast('Welcome back!', 'success')
// If there's a redirect param (e.g., from invite link), go there
router.push(redirectTo || '/today')

View File

@@ -37,6 +37,7 @@ function RegisterForm() {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name, email, password }),
})
@@ -47,6 +48,15 @@ function RegisterForm() {
return
}
const sessionResponse = await fetch('/api/auth/me', {
credentials: 'include',
cache: 'no-store',
})
if (!sessionResponse.ok) {
throw new Error('Your account was created, but the session is not available yet. Please sign in again.')
}
showToast('Account created! Let\'s get started.', 'success')
// If there's a redirect param (e.g., from invite link), go there instead of onboarding
router.push(redirectTo || '/onboarding')

View File

@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it } from 'vitest'
import { shouldUseSecureCookies } from './cookies'
const originalCookieSecure = process.env.COOKIE_SECURE
afterEach(() => {
process.env.COOKIE_SECURE = originalCookieSecure
})
describe('shouldUseSecureCookies', () => {
it('uses secure cookies for forwarded https requests even in development', () => {
expect(shouldUseSecureCookies({ forwardedProto: 'https' })).toBe(true)
})
it('uses secure cookies when the request origin is https', () => {
expect(
shouldUseSecureCookies({ origin: 'https://debianvm.kangaroo-eel.ts.net:10000' })
).toBe(true)
})
it('allows an explicit insecure override', () => {
process.env.COOKIE_SECURE = 'false'
expect(shouldUseSecureCookies({ forwardedProto: 'https' })).toBe(false)
})
})

22
src/lib/auth/cookies.ts Normal file
View File

@@ -0,0 +1,22 @@
interface CookieRequestMetadata {
forwardedProto?: string | null
origin?: string | null
referer?: string | null
}
export function shouldUseSecureCookies(metadata: CookieRequestMetadata = {}): boolean {
if (process.env.COOKIE_SECURE === 'false') {
return false
}
const forwardedProto = metadata.forwardedProto?.split(',')[0]?.trim().toLowerCase()
if (forwardedProto) {
return forwardedProto === 'https'
}
if (metadata.origin?.startsWith('https://') || metadata.referer?.startsWith('https://')) {
return true
}
return process.env.NODE_ENV === 'production'
}

View File

@@ -1,6 +1,7 @@
import { cookies } from 'next/headers'
import { prisma } from '@/lib/db/prisma'
import { nanoid } from 'nanoid'
import { shouldUseSecureCookies } from './cookies'
const SESSION_COOKIE_NAME = 'nextstep_session'
const SESSION_MAX_AGE_DAYS = parseInt(process.env.SESSION_MAX_AGE_DAYS || '30', 10)
@@ -89,32 +90,33 @@ export function setSessionCookie(token: string): void {
// happens in the API route response
}
export function getSessionCookieConfig(token: string) {
interface CookieRequestMetadata {
forwardedProto?: string | null
origin?: string | null
referer?: string | null
}
export function getSessionCookieConfig(token: string, metadata?: CookieRequestMetadata) {
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + SESSION_MAX_AGE_DAYS)
// Allow disabling secure cookies for internal/Tailscale networks
const requireHttps = process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production'
return {
name: SESSION_COOKIE_NAME,
value: token,
httpOnly: true,
secure: requireHttps,
secure: shouldUseSecureCookies(metadata),
sameSite: 'lax' as const,
expires: expiresAt,
path: '/',
}
}
export function getSessionCookieClearConfig() {
const requireHttps = process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production'
export function getSessionCookieClearConfig(metadata?: CookieRequestMetadata) {
return {
name: SESSION_COOKIE_NAME,
value: '',
httpOnly: true,
secure: requireHttps,
secure: shouldUseSecureCookies(metadata),
sameSite: 'lax' as const,
expires: new Date(0),
path: '/',

View File

@@ -0,0 +1,9 @@
export function isDue(scheduledTime: string, now: Date): boolean {
const [hours, minutes] = scheduledTime.split(':').map(Number)
const nowMinutes = now.getHours() * 60 + now.getMinutes()
const schedMinutes = hours * 60 + minutes
// The sender is expected to run every minute. Matching the exact minute
// prevents the same reminder from being sent repeatedly across a tolerance window.
return nowMinutes === schedMinutes
}

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { isDue } from './due'
describe('notification scheduler', () => {
it('matches only the exact scheduled minute', () => {
expect(isDue('09:00', new Date('2024-01-15T09:00:00+08:00'))).toBe(true)
expect(isDue('09:00', new Date('2024-01-15T08:59:00+08:00'))).toBe(false)
expect(isDue('09:00', new Date('2024-01-15T09:01:00+08:00'))).toBe(false)
})
})

View File

@@ -1,5 +1,6 @@
import { prisma } from '@/lib/db/prisma'
import { sendPushNotification } from './push'
import { isDue } from './due'
interface MedicationSchedule {
medicationId: string
@@ -34,18 +35,6 @@ function isQuietHours(
return currentMinutes >= startMinutes && currentMinutes < endMinutes
}
/**
* Check if a medication dose is due at the current time
*/
function isDue(scheduledTime: string, now: Date, toleranceMinutes = 5): boolean {
const [hours, minutes] = scheduledTime.split(':').map(Number)
const nowMinutes = now.getHours() * 60 + now.getMinutes()
const schedMinutes = hours * 60 + minutes
// Due if within tolerance window
return Math.abs(nowMinutes - schedMinutes) <= toleranceMinutes
}
/**
* Get all medication schedules that need notifications sent
* This should be called by a cron job or similar