diff --git a/! b/! new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 1d8bd45..4ae4779 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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: { diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 86439d1..f92c371 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -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 diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 2f97bf7..2e15346 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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, diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 616d95a..73cece0 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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') diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index b1d9c91..fc03c5a 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -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') diff --git a/src/lib/auth/cookies.test.ts b/src/lib/auth/cookies.test.ts new file mode 100644 index 0000000..dcc3482 --- /dev/null +++ b/src/lib/auth/cookies.test.ts @@ -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) + }) +}) diff --git a/src/lib/auth/cookies.ts b/src/lib/auth/cookies.ts new file mode 100644 index 0000000..348cd34 --- /dev/null +++ b/src/lib/auth/cookies.ts @@ -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' +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 4c666d8..7a51553 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -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: '/', diff --git a/src/lib/notifications/due.ts b/src/lib/notifications/due.ts new file mode 100644 index 0000000..f00be4c --- /dev/null +++ b/src/lib/notifications/due.ts @@ -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 +} diff --git a/src/lib/notifications/scheduler.test.ts b/src/lib/notifications/scheduler.test.ts new file mode 100644 index 0000000..4606fbf --- /dev/null +++ b/src/lib/notifications/scheduler.test.ts @@ -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) + }) +}) diff --git a/src/lib/notifications/scheduler.ts b/src/lib/notifications/scheduler.ts index 79c9221..ce44862 100644 --- a/src/lib/notifications/scheduler.ts +++ b/src/lib/notifications/scheduler.ts @@ -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