mirror of
https://github.com/Tony0410/nextstep.git
synced 2026-05-24 21:31:43 +08:00
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:
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
25
src/lib/auth/cookies.test.ts
Normal file
25
src/lib/auth/cookies.test.ts
Normal 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
22
src/lib/auth/cookies.ts
Normal 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'
|
||||
}
|
||||
@@ -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: '/',
|
||||
|
||||
9
src/lib/notifications/due.ts
Normal file
9
src/lib/notifications/due.ts
Normal 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
|
||||
}
|
||||
10
src/lib/notifications/scheduler.test.ts
Normal file
10
src/lib/notifications/scheduler.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user